sync mode on

This commit is contained in:
JotaChina committed 2025-10-26 16:43:01 -03:00
1 parent 1818932244
commit 1174b328e5
415 files changed
+43921 -809

No files matched your search

+46
View File
@@ -186,6 +186,52 @@ body.sidebar-hidden .sample-browser {
border: 1px solid var(--border-color-light);
}
/* Estilo para o novo menu de contexto da régua */
#ruler-context-menu {
display: none;
position: fixed;
z-index: 10000;
background-color: var(--background-dark);
border: 1px solid var(--border-color-dark);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
font-size: 0.8rem;
padding: 5px 0;
min-width: 150px;
}
#ruler-context-menu > div {
padding: 6px 15px;
cursor: pointer;
color: var(--text-light);
}
#ruler-context-menu > div:hover {
background-color: var(--accent-blue);
color: #fff;
}
/* Estilo para clipes "recortados" (cut) */
.timeline-clip.cut {
opacity: 0.5;
border-style: dashed;
}
/* =============================================== */
/* SALA COMPARTILHADA
/* =============================================== */
#create-room-btn {
width: auto; /* Permite que o botão se ajuste ao texto */
padding-left: 12px;
padding-right: 16px;
color: var(--text-light); /* Ou a cor que preferir */
}
#create-room-btn:hover {
background-color: var(--accent-color); /* Destaque ao passar o mouse */
color: var(--text-dark);
}
/* =============================================== */
/* EDITOR DE BASES (BEAT EDITOR / STEP SEQUENCER)
/* =============================================== */
+7 -2
View File
@@ -1,5 +1,7 @@
// js/audio.js
import * as Tone from "https://esm.sh/tone";
// O contexto de áudio agora será gerenciado principalmente pelo Tone.js.
// Esta função garante que ele seja iniciado por uma interação do usuário.
export function initializeAudioContext() {
@@ -9,10 +11,13 @@ export function initializeAudioContext() {
}
}
// Funções de acesso ao contexto global do Tone.js
// ✅ DEPOIS: devolve o *raw* AudioContext (nativo do Web Audio)
export function getAudioContext() {
return Tone.context;
// compatível com versões novas/antigas do Tone
const ctx = typeof Tone.getContext === 'function' ? Tone.getContext() : Tone.context;
return ctx.rawContext || ctx; // rawContext quando existir
}
export function getMainGainNode() {
return Tone.Destination;
}
+134 -70
View File
@@ -1,8 +1,10 @@
// js/audio/audio_audio.js
import { appState } from "../state.js";
import { updateAudioEditorUI, updatePlayheadVisual, resetPlayheadVisual } from "./audio_ui.js";
import { initializeAudioContext, getAudioContext } from "../audio.js";
import { initializeAudioContext, getAudioContext, getMainGainNode } from "../audio.js";
import { getPixelsPerSecond } from "../utils.js";
// 🔊 ADIÇÃO: usar a MESMA instância do Tone que o projeto usa
import * as Tone from "https://esm.sh/tone";
// --- Configurações do Scheduler ---
const LOOKAHEAD_INTERVAL_MS = 25.0;
@@ -16,16 +18,17 @@ let animationFrameId = null;
// Sincronização de Tempo
let startTime = 0;
let seekTime = 0;
let logicalPlaybackTime = 0;
// (seek/logical ficam em appState.audio)
// Configurações de Loop
let isLoopActive = false;
let loopStartTimeSec = 0;
let loopEndTimeSec = 8;
// estado runtime
const runtimeClipState = new Map();
const scheduledNodes = new Map();
// ⚠️ agora armazenamos Tone.Player em vez de BufferSource
const scheduledNodes = new Map(); // eventId -> { player, clipId }
let nextEventId = 0;
const callbacks = {
@@ -42,49 +45,78 @@ function _getSecondsPerBeat() { return 60.0 / _getBpm(); }
function _convertBeatToSeconds(beat) { return beat * _getSecondsPerBeat(); }
function _convertSecondsToBeat(seconds) { return seconds / _getSecondsPerBeat(); }
// garante um único contexto — o rawContext do Tone
function _initContext() {
if (!audioCtx) {
initializeAudioContext();
audioCtx = getAudioContext();
audioCtx = getAudioContext(); // deve ser o rawContext do Tone
}
}
// --- Lógica Principal do Scheduler (sem alterações) ---
// helper: normaliza AudioBuffer → ToneAudioBuffer (mesmo contexto)
function _toToneBuffer(buffer) {
if (!buffer) return null;
if (buffer._buffer) return buffer; // já é Tone.ToneAudioBuffer
const tab = new Tone.ToneAudioBuffer();
tab._buffer = buffer; // injeta o AudioBuffer (já no rawContext do Tone)
return tab;
}
// --- Lógica Principal do Scheduler (mantida) ---
function _scheduleClip(clip, absolutePlayTime, durationSec) {
if (!clip.buffer) {
console.warn(`Clip ${clip.id} não possui áudio buffer carregado.`);
return;
}
if (!clip.gainNode || !clip.pannerNode) {
console.warn(`Clip ${clip.id} não possui gainNode ou pannerNode.`);
return;
}
const source = new Tone.BufferSource(clip.buffer);
source.connect(clip.gainNode);
// usamos Player .sync() conectando no mesmo grafo do Tone
const toneBuf = _toToneBuffer(clip.buffer);
if (!toneBuf) return;
// --- CORREÇÃO: Aplica o pitch (que pode ser de stretch ou wheel) ---
if (clip.pitch && clip.pitch !== 0) {
source.playbackRate.value = Math.pow(2, clip.pitch / 12);
} else {
source.playbackRate.value = 1.0; // Garante que o modo 'trim' toque normal
}
// --- FIM DA CORREÇÃO ---
// cadeia de ganho/pan por clipe (se já tiver no estado, use; aqui garantimos)
const gain = clip.gainNode instanceof Tone.Gain ? clip.gainNode : new Tone.Gain(clip.volume ?? 1);
const pan = clip.pannerNode instanceof Tone.Panner ? clip.pannerNode : new Tone.Panner(clip.pan ?? 0);
// conecta no destino principal (é um ToneAudioNode)
try {
gain.disconnect(); // evita duplicatas caso exista de execuções anteriores
} catch {}
try {
pan.disconnect();
} catch {}
gain.connect(pan).connect(getMainGainNode());
// player sincronizado no Transport
const player = new Tone.Player(toneBuf).sync().connect(gain);
// aplica pitch como rate (semitons → rate)
const rate = (clip.pitch && clip.pitch !== 0) ? Math.pow(2, clip.pitch / 12) : 1;
player.playbackRate = rate;
// calculamos o "when" no tempo do Transport:
// absolutePlayTime é em audioCtx.currentTime; o "zero" lógico foi quando demos play:
// logical = (now - startTime) + seek; => occurrence = (absolutePlayTime - startTime) + seek
const occurrenceInTransportSec = (absolutePlayTime - startTime) + (appState.audio.audioEditorSeekTime || 0);
const offset = clip.offsetInSeconds ?? clip.offset ?? 0;
const dur = durationSec ?? toneBuf.duration;
// agenda
player.start(occurrenceInTransportSec, offset, dur);
const eventId = nextEventId++;
const clipOffset = clip.offsetInSeconds || clip.offset || 0;
source.start(absolutePlayTime, clipOffset, durationSec);
scheduledNodes.set(eventId, { sourceNode: source, clipId: clip.id });
scheduledNodes.set(eventId, { player, clipId: clip.id });
if (callbacks.onClipScheduled) {
callbacks.onClipScheduled(clip);
}
source.onended = () => {
// quando parar naturalmente, limpamos runtime
player.onstop = () => {
_handleClipEnd(eventId, clip.id);
source.dispose();
try { player.unsync(); } catch {}
try { player.dispose(); } catch {}
};
}
@@ -102,26 +134,18 @@ function _schedulerTick() {
if (!isPlaying || !audioCtx) return;
const now = audioCtx.currentTime;
const logicalTime = (now - startTime) + seekTime;
const logicalTime = (now - startTime) + (appState.audio.audioEditorSeekTime || 0);
const scheduleWindowStartSec = logicalTime;
const scheduleWindowEndSec = logicalTime + SCHEDULE_AHEAD_TIME_SEC;
for (const clip of appState.audio.clips) {
const clipRuntime = runtimeClipState.get(clip.id) || { isScheduled: false };
if (clipRuntime.isScheduled) {
continue;
}
if (!clip.buffer) {
continue;
}
if (clipRuntime.isScheduled) continue;
if (!clip.buffer) continue;
const clipStartTimeSec = clip.startTimeInSeconds;
const clipDurationSec = clip.durationInSeconds;
if (typeof clipStartTimeSec === 'undefined' || typeof clipDurationSec === 'undefined') {
continue;
}
if (typeof clipStartTimeSec === 'undefined' || typeof clipDurationSec === 'undefined') continue;
let occurrenceStartTimeSec = clipStartTimeSec;
@@ -137,11 +161,12 @@ function _schedulerTick() {
occurrenceStartTimeSec += loopsMissed * loopDuration;
}
}
if (
occurrenceStartTimeSec >= scheduleWindowStartSec &&
occurrenceStartTimeSec < scheduleWindowEndSec
) {
const absolutePlayTime = startTime + (occurrenceStartTimeSec - seekTime);
const absolutePlayTime = startTime + (occurrenceStartTimeSec - (appState.audio.audioEditorSeekTime || 0));
_scheduleClip(clip, absolutePlayTime, clipDurationSec);
clipRuntime.isScheduled = true;
runtimeClipState.set(clip.id, clipRuntime);
@@ -149,23 +174,26 @@ function _schedulerTick() {
}
}
// --- Loop de Animação (sem alterações) ---
// --- Loop de Animação (mantido) ---
function _animationLoop() {
if (!isPlaying) {
animationFrameId = null;
return;
}
const now = audioCtx.currentTime;
let newLogicalTime = (now - startTime) + seekTime;
let newLogicalTime = (now - startTime) + (appState.audio.audioEditorSeekTime || 0);
if (isLoopActive) {
if (newLogicalTime >= loopEndTimeSec) {
const loopDuration = loopEndTimeSec - loopStartTimeSec;
newLogicalTime = loopStartTimeSec + ((newLogicalTime - loopStartTimeSec) % loopDuration);
startTime = now;
seekTime = newLogicalTime;
appState.audio.audioEditorSeekTime = newLogicalTime;
}
}
logicalPlaybackTime = newLogicalTime;
appState.audio.audioEditorLogicalTime = newLogicalTime;
if (!isLoopActive) {
let maxTime = 0;
appState.audio.clips.forEach(clip => {
@@ -174,15 +202,14 @@ function _animationLoop() {
const endTime = clipStartTime + clipDuration;
if (endTime > maxTime) maxTime = endTime;
});
if (maxTime > 0 && logicalPlaybackTime >= maxTime) {
if (maxTime > 0 && appState.audio.audioEditorLogicalTime >= maxTime) {
stopAudioEditorPlayback(true); // Rebobina no fim
resetPlayheadVisual();
return;
}
}
const pixelsPerSecond = getPixelsPerSecond();
const newPositionPx = logicalPlaybackTime * pixelsPerSecond;
const newPositionPx = appState.audio.audioEditorLogicalTime * pixelsPerSecond;
updatePlayheadVisual(newPositionPx);
animationFrameId = requestAnimationFrame(_animationLoop);
}
@@ -196,76 +223,108 @@ export function updateTransportLoop() {
runtimeClipState.clear();
scheduledNodes.forEach(nodeData => {
// --- CORREÇÃO BUG 1: Remove a linha 'onended = null' ---
nodeData.sourceNode.stop(0);
nodeData.sourceNode.dispose();
// parar e descartar players agendados
scheduledNodes.forEach(({ player }) => {
try { player.unsync(); } catch {}
try { player.stop(); } catch {}
try { player.dispose(); } catch {}
});
scheduledNodes.clear();
}
export function startAudioEditorPlayback() {
export async function startAudioEditorPlayback(seekTime) { // 1. Aceita 'seekTime' como parâmetro
if (isPlaying) return;
_initContext();
// garante contexto ativo do Tone (gesto do usuário já ocorreu antes)
await Tone.start();
if (audioCtx.state === 'suspended') {
audioCtx.resume();
await audioCtx.resume();
}
isPlaying = true;
// --- CORREÇÃO BUG 2: Atualiza o estado global ---
appState.global.isAudioEditorPlaying = true;
// alinhamento de relógio próprio (mantido para o seu scheduler)
startTime = audioCtx.currentTime;
// =================================================================
// 👇 INÍCIO DA CORREÇÃO (Bugs 1 & 2)
// =================================================================
// 1. Determine o tempo de início:
// Use o 'seekTime' recebido (da ação global) se for um número válido (>= 0).
// Caso contrário, use o tempo de seek local atual.
const timeToStart = (seekTime !== null && seekTime !== undefined && !isNaN(seekTime))
? seekTime
: (appState.audio.audioEditorSeekTime || 0); // 👈 Usa sua variável de estado
// 2. Atualize o estado global (para a agulha pular)
// Isso garante que o estado local E o Tone estejam sincronizados.
appState.audio.audioEditorSeekTime = timeToStart;
// 3. Alinhe o Tone.Transport a esse tempo
try {
Tone.Transport.seconds = timeToStart; // 👈 Usa o tempo sincronizado
} catch {}
// =================================================================
// 👆 FIM DA CORREÇÃO
// =================================================================
updateTransportLoop();
console.log("%cIniciando Playback...", "color: #3498db;");
// inicia o Transport (para disparar os Players .sync())
try {
Tone.Transport.start();
} catch {}
// mantém seu scheduler/animador
_schedulerTick();
schedulerIntervalId = setInterval(_schedulerTick, LOOKAHEAD_INTERVAL_MS);
animationFrameId = requestAnimationFrame(_animationLoop);
updateAudioEditorUI();
const playBtn = document.getElementById("audio-editor-play-btn");
if (playBtn) {
playBtn.className = 'fa-solid fa-pause';
}
if (playBtn) playBtn.className = "fa-solid fa-pause";
}
export function stopAudioEditorPlayback(rewind = false) {
if (!isPlaying) return;
isPlaying = false;
// --- CORREÇÃO BUG 2: Atualiza o estado global ---
appState.global.isAudioEditorPlaying = false;
console.log(`%cParando Playback... (Rewind: ${rewind})`, "color: #d9534f;");
// para o Transport (para Players .sync())
try { Tone.Transport.stop(); } catch {}
clearInterval(schedulerIntervalId);
schedulerIntervalId = null;
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
seekTime = logicalPlaybackTime;
logicalPlaybackTime = 0;
appState.audio.audioEditorSeekTime = appState.audio.audioEditorLogicalTime || 0;
appState.audio.audioEditorLogicalTime = 0;
if (rewind) {
seekTime = 0;
appState.audio.audioEditorSeekTime = 0;
try { Tone.Transport.seconds = 0; } catch {}
}
scheduledNodes.forEach(nodeData => {
// --- CORREÇÃO BUG 1: Remove a linha 'onended = null' ---
nodeData.sourceNode.stop(0);
nodeData.sourceNode.dispose();
// parar e descartar players agendados
scheduledNodes.forEach(({ player }) => {
try { player.unsync(); } catch {}
try { player.stop(); } catch {}
try { player.dispose(); } catch {}
});
scheduledNodes.clear();
runtimeClipState.clear();
updateAudioEditorUI();
const playBtn = document.getElementById("audio-editor-play-btn");
if (playBtn) {
playBtn.className = 'fa-solid fa-play';
}
if (playBtn) playBtn.className = 'fa-solid fa-play';
if (rewind) {
resetPlayheadVisual();
@@ -284,11 +343,16 @@ export function seekAudioEditor(newTime) {
if (wasPlaying) {
stopAudioEditorPlayback(false); // Pausa
}
seekTime = newTime;
logicalPlaybackTime = newTime;
appState.audio.audioEditorSeekTime = newTime;
appState.audio.audioEditorLogicalTime = newTime;
try { Tone.Transport.seconds = newTime; } catch {}
const pixelsPerSecond = getPixelsPerSecond();
const newPositionPx = newTime * pixelsPerSecond;
updatePlayheadVisual(newPositionPx);
if (wasPlaying) {
startAudioEditorPlayback();
}
@@ -0,0 +1,118 @@
// js/audio/audio_clipboard.js
import { appState } from '../state.js';
import { removeAudioClip, loadAudioForClip } from './audio_state.js';
import { renderAudioEditor } from './audio_ui.js';
import { getMainGainNode } from '../audio.js';
/**
* Copia o clipe selecionado para a área de transferência global.
*/
export function copyAudioClip() {
const clipId = appState.global.selectedClipId;
if (!clipId) return;
const clip = appState.audio.clips.find(c => c.id == clipId);
if (!clip) return;
// Remove a marca de "recortado" se houver
if (appState.global.clipboard?.cutSourceId) {
appState.global.clipboard.cutSourceId = null;
}
// Cria uma cópia limpa dos dados do clipe
const clipData = { ...clip };
// Remove referências a nós de áudio, que devem ser únicos
delete clipData.gainNode;
delete clipData.pannerNode;
delete clipData.player;
appState.global.clipboard = {
type: 'audio',
clip: clipData,
cutSourceId: null
};
console.log("Clipe copiado:", appState.global.clipboard.clip.name);
renderAudioEditor(); // Re-renderiza para remover o visual "cut"
}
/**
* Recorta o clipe selecionado para a área de transferência global.
*/
export function cutAudioClip() {
const clipId = appState.global.selectedClipId;
if (!clipId) return;
// Limpa o "cut" anterior
if (appState.global.clipboard?.cutSourceId) {
appState.global.clipboard.cutSourceId = null;
}
const clip = appState.audio.clips.find(c => c.id == clipId);
if (!clip) return;
// Cria uma cópia limpa dos dados do clipe
const clipData = { ...clip };
delete clipData.gainNode;
delete clipData.pannerNode;
delete clipData.player;
appState.global.clipboard = {
type: 'audio',
clip: clipData,
cutSourceId: clipId // Marca o ID original para exclusão
};
console.log("Clipe recortado:", appState.global.clipboard.clip.name);
renderAudioEditor(); // Re-renderiza para adicionar o visual "cut"
}
/**
* Cola o clipe da área de transferência na timeline.
* @param {number} targetTrackId - O ID da pista onde colar.
* @param {number} targetTimeInSeconds - O tempo (em segundos) onde colar.
*/
export function pasteAudioClip(targetTrackId, targetTimeInSeconds) {
const clipboard = appState.global.clipboard;
if (!clipboard || clipboard.type !== 'audio' || !clipboard.clip) {
console.warn("Área de transferência vazia ou inválida para colar áudio.");
return;
}
const clipToPaste = clipboard.clip;
// Se for um "recorte", primeiro remove o clipe original
if (clipboard.cutSourceId) {
removeAudioClip(clipboard.cutSourceId); //
clipboard.cutSourceId = null; // Limpa para que não remova de novo
}
// Cria um novo clipe a partir dos dados copiados
const newClip = {
...clipToPaste,
id: Date.now() + Math.random(), // ID novo e único
trackId: targetTrackId,
startTimeInSeconds: targetTimeInSeconds,
// Cria novos nós de áudio
gainNode: new Tone.Gain(Tone.gainToDb(clipToPaste.volume || 1.0)),
pannerNode: new Tone.Panner(clipToPaste.pan || 0),
player: null,
// O buffer será copiado/referenciado
};
newClip.gainNode.connect(newClip.pannerNode);
newClip.pannerNode.connect(getMainGainNode()); //
appState.audio.clips.push(newClip);
// Como o buffer já deve existir no clipe original,
// não precisamos de 'loadAudioForClip', mas chamamos
// para garantir (caso a fonte seja um 'sourcePath').
// A função 'loadAudioForClip' precisa ser inteligente
// para não recarregar se o buffer já existir.
loadAudioForClip(newClip).then(() => { //
renderAudioEditor();
});
}
+144 -29
View File
@@ -1,18 +1,89 @@
// js/audio_state.js
// js/audio/audio_state.js
import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js";
import { renderAudioEditor } from "./audio_ui.js";
import { getMainGainNode } from "../audio.js";
import { getAudioContext } from "../audio.js";
import { getMainGainNode, getAudioContext } from "../audio.js";
import * as Tone from "https://esm.sh/tone";
export let audioState = {
tracks: [],
clips: [],
// --- TEMPOS MOVIDOS DO audio_audio.js PARA O ESTADO GLOBAL ---
audioEditorSeekTime: 0,
audioEditorLogicalTime: 0,
// --- FIM DA MUDANÇA ---
audioEditorStartTime: 0,
audioEditorAnimationId: null,
audioEditorPlaybackTime: 0,
isAudioEditorLoopEnabled: false,
};
// ==== SNAPSHOT: exportação do estado atual (tracks + clips) ====
export function getAudioSnapshot() {
// Se seu estado “oficial” é audioState.* use ele;
// se for appState.audio.* troque abaixo.
const tracks = (audioState.tracks || []).map(t => ({
id: t.id, name: t.name
}));
const clips = (audioState.clips || []).map(c => ({
id: c.id,
trackId: c.trackId,
name: c.name,
sourcePath: c.sourcePath || null, // URL do asset (precisa ser acessível)
startTimeInSeconds: c.startTimeInSeconds || 0,
durationInSeconds: c.durationInSeconds || (c.buffer?.duration || 0),
offset: c.offset || 0,
pitch: c.pitch || 0,
volume: c.volume ?? 1,
pan: c.pan ?? 0,
originalDuration: c.originalDuration || (c.buffer?.duration || 0),
}));
return { tracks, clips };
}
// ==== SNAPSHOT: aplicação do estado recebido ====
export async function applyAudioSnapshot(snapshot) {
if (!snapshot) return;
// aplica trilhas (mantém ids/nome)
if (Array.isArray(snapshot.tracks) && snapshot.tracks.length) {
audioState.tracks = snapshot.tracks.map(t => ({ id: t.id, name: t.name }));
}
// insere clipes usando os MESMOS ids do emissor (idempotente)
if (Array.isArray(snapshot.clips)) {
for (const c of snapshot.clips) {
// evita duplicar se já existir (idempotência)
if (audioState.clips.some(x => String(x.id) === String(c.id))) continue;
// usa a própria função de criação (agora ela aceita id e nome)
// assinatura: addAudioClipToTimeline(samplePath, trackId, start, clipId, name)
addAudioClipToTimeline(c.sourcePath, c.trackId, c.startTimeInSeconds, c.id, c.name);
// aplica propriedades adicionais (dur/offset/pitch/vol/pan) no mesmo id
const idx = audioState.clips.findIndex(x => String(x.id) === String(c.id));
if (idx >= 0) {
const clip = audioState.clips[idx];
clip.durationInSeconds = c.durationInSeconds;
clip.offset = c.offset;
clip.pitch = c.pitch;
clip.volume = c.volume;
clip.pan = c.pan;
clip.originalDuration = c.originalDuration;
// reflete nos nós Tone já criados
if (clip.gainNode) clip.gainNode.gain.value = clip.volume ?? 1;
if (clip.pannerNode) clip.pannerNode.pan.value = clip.pan ?? 0;
}
}
}
// re-render geral do editor
renderAudioEditor();
}
export function initializeAudioState() {
audioState.clips.forEach(clip => {
if (clip.pannerNode) clip.pannerNode.dispose();
@@ -21,6 +92,10 @@ export function initializeAudioState() {
Object.assign(audioState, {
tracks: [],
clips: [],
// --- ADICIONADO ---
audioEditorSeekTime: 0,
audioEditorLogicalTime: 0,
// --- FIM ---
audioEditorStartTime: 0,
audioEditorAnimationId: null,
audioEditorPlaybackTime: 0,
@@ -29,6 +104,16 @@ export function initializeAudioState() {
}
export async function loadAudioForClip(clip) {
// --- ADIÇÃO ---
// Se já temos um buffer (do bounce ou colagem), não faz fetch
if (clip.buffer) {
// Garante que as durações estão corretas
if (clip.originalDuration === 0) clip.originalDuration = clip.buffer.duration;
if (clip.durationInSeconds === 0) clip.durationInSeconds = clip.buffer.duration;
return clip;
}
// --- FIM DA ADIÇÃO ---
if (!clip.sourcePath) return clip;
const audioCtx = getAudioContext();
@@ -58,51 +143,82 @@ export async function loadAudioForClip(clip) {
return clip;
}
export function addAudioClipToTimeline(samplePath, trackId = 1, startTime = 0, clipName = null) {
const newClip = {
id: Date.now() + Math.random(),
trackId: trackId,
sourcePath: samplePath,
// helper de id (fallback se o emissor não mandar)
function genClipId() {
return (crypto?.randomUUID?.() || `clip_${Date.now()}_${Math.floor(Math.random()*1e6)}`);
}
// --- MODIFICAÇÃO AQUI ---
// Usa o nome fornecido, ou extrai do caminho se não for fornecido
name: clipName || samplePath.split('/').pop(),
// --- FUNÇÃO MODIFICADA ---
// agora aceita clipId e clipName vindos do emissor; mantém compat com chamadas antigas
export function addAudioClipToTimeline(samplePath, trackId = 1, startTime = 0, clipIdOrName = null, nameOrBuffer = null, maybeBuffer = null) {
// compat: se passaram (filePath, trackId, start, clipId)
// mas versões antigas chamavam (filePath, trackId, start) ou (filePath, trackId, start, name, buffer)
let incomingId = null;
let clipName = null;
let existingBuffer = null;
// heurística: se clipIdOrName parece um UUID/clip_ → é id, senão é nome
if (typeof clipIdOrName === 'string' && (clipIdOrName.startsWith('clip_') || clipIdOrName.length >= 16)) {
incomingId = clipIdOrName;
clipName = (typeof nameOrBuffer === 'string') ? nameOrBuffer : null;
existingBuffer = maybeBuffer || (nameOrBuffer && typeof nameOrBuffer !== 'string' ? nameOrBuffer : null);
} else {
// assinatura antiga: 4º arg era nome
clipName = (typeof clipIdOrName === 'string') ? clipIdOrName : null;
existingBuffer = (nameOrBuffer && typeof nameOrBuffer !== 'string') ? nameOrBuffer : null;
}
const finalId = incomingId || genClipId();
// idempotência: se o id já existe, não duplica
if (audioState.clips.some(c => String(c.id) === String(finalId))) {
return;
}
const newClip = {
id: finalId,
trackId: trackId,
sourcePath: samplePath, // Pode ser null se existingBuffer for fornecido
name: clipName || (samplePath ? String(samplePath).split('/').pop() : 'Bounced Clip'),
startTimeInSeconds: startTime,
offset: 0,
durationInSeconds: 0,
originalDuration: 0, // Será preenchido pelo loadAudioForClip
originalDuration: 0,
pitch: 0,
volume: DEFAULT_VOLUME,
pan: DEFAULT_PAN,
gainNode: new Tone.Gain(Tone.gainToDb(DEFAULT_VOLUME)),
pannerNode: new Tone.Panner(DEFAULT_PAN),
buffer: null,
buffer: existingBuffer || null,
player: null,
};
// volume linear (0–1)
newClip.gainNode = new Tone.Gain(DEFAULT_VOLUME);
newClip.pannerNode = new Tone.Panner(DEFAULT_PAN);
// conecta tudo no grafo do Tone (mesmo contexto)
newClip.gainNode.connect(newClip.pannerNode);
newClip.pannerNode.connect(getMainGainNode());
audioState.clips.push(newClip);
// loadAudioForClip agora vai lidar com 'existingBuffer'
loadAudioForClip(newClip).then(() => {
renderAudioEditor();
});
}
export function updateAudioClipProperties(clipId, properties) {
const clip = audioState.clips.find(c => c.id == clipId);
const clip = audioState.clips.find(c => String(c.id) == String(clipId));
if (clip) {
Object.assign(clip, properties);
}
}
export function sliceAudioClip(clipId, sliceTimeInTimeline) {
const originalClip = audioState.clips.find(c => c.id == clipId);
const originalClip = audioState.clips.find(c => String(c.id) == String(clipId));
if (!originalClip ||
sliceTimeInTimeline <= originalClip.startTimeInSeconds ||
@@ -115,7 +231,7 @@ export function sliceAudioClip(clipId, sliceTimeInTimeline) {
const cutPointInClip = sliceTimeInTimeline - originalClip.startTimeInSeconds;
const newClip = {
id: Date.now() + Math.random(),
id: genClipId(),
trackId: originalClip.trackId,
sourcePath: originalClip.sourcePath,
name: originalClip.name,
@@ -132,7 +248,7 @@ export function sliceAudioClip(clipId, sliceTimeInTimeline) {
volume: originalClip.volume,
pan: originalClip.pan,
gainNode: new Tone.Gain(Tone.gainToDb(originalClip.volume)),
gainNode: new Tone.Gain(originalClip.volume),
pannerNode: new Tone.Panner(originalClip.pan),
player: null
@@ -148,20 +264,19 @@ export function sliceAudioClip(clipId, sliceTimeInTimeline) {
console.log("Clipe dividido. Original:", originalClip, "Novo:", newClip);
}
// ... (resto do arquivo 'audio_state.js' sem alterações) ...
export function updateClipVolume(clipId, volume) {
const clip = audioState.clips.find((c) => c.id == clipId);
const clip = audioState.clips.find((c) => String(c.id) == String(clipId));
if (clip) {
const clampedVolume = Math.max(0, Math.min(1.5, volume));
clip.volume = clampedVolume;
if (clip.gainNode) {
clip.gainNode.gain.value = Tone.gainToDb(clampedVolume);
clip.gainNode.gain.value = clampedVolume;
}
}
}
export function updateClipPan(clipId, pan) {
const clip = audioState.clips.find((c) => c.id == clipId);
const clip = audioState.clips.find((c) => String(c.id) == String(clipId));
if (clip) {
const clampedPan = Math.max(-1, Math.min(1, pan));
clip.pan = clampedPan;
@@ -177,19 +292,19 @@ export function addAudioTrackLane() {
}
export function removeAudioClip(clipId) {
const clipIndex = audioState.clips.findIndex(c => c.id == clipId);
const clipIndex = audioState.clips.findIndex(c => String(c.id) == String(clipId));
if (clipIndex === -1) return false; // Retorna false se não encontrou
const clip = audioState.clips[clipIndex];
// 1. Limpa os nós de áudio do Tone.js
if (clip.gainNode) {
clip.gainNode.disconnect();
clip.gainNode.dispose();
try { clip.gainNode.disconnect(); } catch {}
try { clip.gainNode.dispose(); } catch {}
}
if (clip.pannerNode) {
clip.pannerNode.disconnect();
clip.pannerNode.dispose();
try { clip.pannerNode.disconnect(); } catch {}
try { clip.pannerNode.dispose(); } catch {}
}
// 2. Remove o clipe do array de estado
File diff suppressed because it is too large. Load diff
-1
View File
@@ -8,7 +8,6 @@ export const NOTE_LENGTH = 12;
export const DEFAULT_VOLUME = 0.8;
export const DEFAULT_PAN = 0.0;
// --- ADICIONADO ---
// Constantes para o layout do editor de áudio
export const PIXELS_PER_STEP = 32; // Cada step (1/16) terá 32px de largura
export const PIXELS_PER_BAR = 512; // 16 steps * 32px/step = 512px por compasso (bar)
+34 -7
View File
@@ -4,6 +4,10 @@ import { loadAudioForTrack } from "./pattern/pattern_state.js";
import { renderAll, getSamplePathMap } from "./ui.js";
import { DEFAULT_PAN, DEFAULT_VOLUME, NOTE_LENGTH } from "./config.js";
import { initializeAudioContext, getAudioContext, getMainGainNode } from "./audio.js";
import * as Tone from "https://esm.sh/tone";
// --- NOVA IMPORTAÇÃO ---
import { sendAction } from "./socket.js";
export async function handleFileLoad(file) {
let xmlContent = "";
@@ -20,7 +24,12 @@ export async function handleFileLoad(file) {
} else {
xmlContent = await file.text();
}
await parseMmpContent(xmlContent);
// ANTES: await parseMmpContent(xmlContent);
// DEPOIS:
// Envia o XML para o servidor, que o transmitirá para todos (incluindo nós)
sendAction({ type: 'LOAD_PROJECT', xml: xmlContent });
} catch (error) {
console.error("Erro ao carregar o projeto:", error);
alert(`Erro ao carregar projeto: ${error.message}`);
@@ -34,8 +43,16 @@ export async function loadProjectFromServer(fileName) {
throw new Error(`Não foi possível carregar o arquivo ${fileName}`);
const xmlContent = await response.text();
await parseMmpContent(xmlContent);
return true;
// ANTES:
// await parseMmpContent(xmlContent);
// return true;
// DEPOIS:
// Envia o XML para o servidor
sendAction({ type: 'LOAD_PROJECT', xml: xmlContent });
return true; // Retorna true para que o modal de UI feche
} catch (error) {
console.error("Erro ao carregar projeto do servidor:", error);
console.error(error);
@@ -44,6 +61,10 @@ export async function loadProjectFromServer(fileName) {
}
}
// --- NENHUMA MUDANÇA DAQUI PARA BAIXO ---
// 'parseMmpContent' agora é chamado pelo 'socket.js'
// quando ele recebe a ação 'LOAD_PROJECT' ou 'load_project_state'.
export async function parseMmpContent(xmlString) {
resetProjectState();
initializeAudioContext();
@@ -175,12 +196,12 @@ export async function parseMmpContent(xmlString) {
let isFirstTrackWithNotes = true;
newTracks.forEach(track => {
// --- INÍCIO DA CORREÇÃO ---
// Cria os nós de áudio usando os construtores do Tone.js
track.gainNode = new Tone.Gain(Tone.gainToDb(track.volume));
// Agora usando Volume em dB (Opção B)
track.volumeNode = new Tone.Volume(Tone.gainToDb(track.volume));
track.pannerNode = new Tone.Panner(track.pan);
// Conecta a cadeia de áudio: Gain -> Panner -> Saída Principal (Destination)
track.gainNode.connect(track.pannerNode);
// Cadeia de áudio: Volume(dB) -> Panner -> Saída Principal
track.volumeNode.connect(track.pannerNode);
track.pannerNode.connect(getMainGainNode());
// --- FIM DA CORREÇÃO ---
@@ -206,7 +227,13 @@ export async function parseMmpContent(xmlString) {
appState.pattern.tracks = newTracks;
appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id || null;
// força atualização total da UI e dos editores de pattern
await Promise.resolve(); // garante que os tracks estejam no estado
renderAll();
console.log('[UI] Projeto renderizado após parseMmpContent');
}
export function generateMmpFile() {
+315 -134
View File
@@ -1,43 +1,65 @@
// js/main.js
// js/main.js (ESM com import absoluto de socket.js + ROOM_NAME local)
import { appState, resetProjectState } from "./state.js";
import { addTrackToState, removeLastTrackFromState } from "./pattern/pattern_state.js";
// --- CORREÇÃO AQUI ---
import { toggleRecording } from "./recording.js";
import { addAudioTrackLane, removeAudioClip } from "./audio/audio_state.js";
import { updateTransportLoop } from "./audio/audio_audio.js";
import {
togglePlayback,
stopPlayback,
rewindPlayback,
} from "./pattern/pattern_audio.js";
import {
startAudioEditorPlayback,
stopAudioEditorPlayback,
updateTransportLoop,
restartAudioEditorIfPlaying,
} from "./audio/audio_audio.js";
import { initializeAudioContext } from "./audio.js";
import { handleFileLoad, generateMmpFile } from "./file.js";
import { renderAll, loadAndRenderSampleBrowser, showOpenProjectModal, closeOpenProjectModal } from "./ui.js";
import {
renderAll,
loadAndRenderSampleBrowser,
showOpenProjectModal,
closeOpenProjectModal,
} from "./ui.js";
import { renderAudioEditor } from "./audio/audio_ui.js";
import { adjustValue, enforceNumericInput } from "./utils.js";
import { ZOOM_LEVELS } from "./config.js";
// --- NOVA FUNÇÃO ---
// Atualiza a aparência dos botões de ferramenta
// ⚠️ IMPORT ABSOLUTO para evitar 404/text/html quando a página estiver em /creation/ ou fora dela.
// Ajuste o prefixo abaixo para o caminho real onde seus assets vivem no servidor:
import { sendAction, joinRoom, setUserName } from "./socket.js";
// Descobre a sala pela URL (local ao main.js) e expõe no window para debug
const ROOM_NAME = new URLSearchParams(window.location.search).get("room");
window.ROOM_NAME = ROOM_NAME;
// ✅ NOVO: se tem sala na URL, entra já na sala (independe do áudio)
if (ROOM_NAME) {
// entra na sala para receber estado/broadcasts imediatamente
joinRoom();
}
// Função util para alternar estado dos botões de ferramenta
function updateToolButtons() {
const sliceToolBtn = document.getElementById("slice-tool-btn");
const trimToolBtn = document.getElementById("resize-tool-trim");
const stretchToolBtn = document.getElementById("resize-tool-stretch");
if(sliceToolBtn) sliceToolBtn.classList.toggle("active", appState.global.sliceToolActive);
if(trimToolBtn) trimToolBtn.classList.toggle("active", !appState.global.sliceToolActive && appState.global.resizeMode === 'trim');
if(stretchToolBtn) stretchToolBtn.classList.toggle("active", !appState.global.sliceToolActive && appState.global.resizeMode === 'stretch');
if (sliceToolBtn)
sliceToolBtn.classList.toggle("active", appState.global.sliceToolActive);
if (trimToolBtn)
trimToolBtn.classList.toggle(
"active",
!appState.global.sliceToolActive && appState.global.resizeMode === "trim"
);
if (stretchToolBtn)
stretchToolBtn.classList.toggle(
"active",
!appState.global.sliceToolActive &&
appState.global.resizeMode === "stretch"
);
// Desativa a ferramenta de corte se outra for selecionada
document.body.classList.toggle("slice-tool-active", appState.global.sliceToolActive);
document.body.classList.toggle(
"slice-tool-active",
appState.global.sliceToolActive
);
}
document.addEventListener("DOMContentLoaded", () => {
// Botões e elementos
const newProjectBtn = document.getElementById("new-project-btn");
const openMmpBtn = document.getElementById("open-mmp-btn");
const saveMmpBtn = document.getElementById("save-mmp-btn");
@@ -53,12 +75,9 @@ document.addEventListener("DOMContentLoaded", () => {
const rewindBtn = document.getElementById("rewind-btn");
const metronomeBtn = document.getElementById("metronome-btn");
const sliceToolBtn = document.getElementById("slice-tool-btn");
// --- NOVOS BOTÕES ---
const resizeToolTrimBtn = document.getElementById("resize-tool-trim");
const resizeToolStretchBtn = document.getElementById("resize-tool-stretch");
const recordBtn = document.getElementById("record-btn");
const createRoomBtn = document.getElementById("create-room-btn");
const mmpFileInput = document.getElementById("mmp-file-input");
const sampleFileInput = document.getElementById("sample-file-input");
const openProjectModal = document.getElementById("open-project-modal");
@@ -68,95 +87,144 @@ document.addEventListener("DOMContentLoaded", () => {
const addBarBtn = document.getElementById("add-bar-btn");
const zoomInBtn = document.getElementById("zoom-in-btn");
const zoomOutBtn = document.getElementById("zoom-out-btn");
const deleteClipBtn = document.getElementById("delete-clip");
// --- LISTENERS ADICIONADOS (COM LÓGICA DE CONTROLLER) ---
// =================================================================
// 👇 INÍCIO DA CORREÇÃO (Botão de Sincronia - Agora envia Ação)
// =================================================================
const syncModeBtn = document.getElementById("sync-mode-btn"); //
if (syncModeBtn) {
//
// Define o estado inicial (global por padrão)
appState.global.syncMode = "global"; //
syncModeBtn.classList.add("active"); //
syncModeBtn.textContent = "Global"; //
// --- NOVO LISTENER PARA O BOTÃO DE GRAVAR ---
if (recordBtn) {
recordBtn.addEventListener("click", () => {
// Garante que o contexto de áudio foi iniciado por um gesto do usuário
initializeAudioContext();
toggleRecording();
syncModeBtn.addEventListener("click", () => {
//
// 1. Determina qual será o *novo* modo
const newMode =
appState.global.syncMode === "global" ? "local" : "global"; //
// 2. Envia a ação para sincronizar. O handleActionBroadcast
// cuidará de atualizar o appState, o botão e mostrar o toast.
sendAction({
type: "SET_SYNC_MODE",
mode: newMode,
});
}
// Listener para o botão "Excluir Clipe" no menu de contexto
const deleteClipBtn = document.getElementById('delete-clip');
// Lógica antiga removida daqui (movida para o handler)
/*
const isNowLocal = appState.global.syncMode === "global";
appState.global.syncMode = isNowLocal ? "local" : "global";
syncModeBtn.classList.toggle("active", !isNowLocal);
syncModeBtn.textContent = isNowLocal ? "Local" : "Global";
showToast( `🎧 Modo de Playback: ${isNowLocal ? "Local" : "Global"}`, "info" );
*/
});
// Esconde o botão se não estiver em uma sala (lógica movida do socket.js)
if (!ROOM_NAME) {
//
//syncModeBtn.style.display = 'none'; // REMOVIDO PARA TESTE VISUAL
}
}
// =================================================================
// 👆 FIM DA CORREÇÃO
// =================================================================
// Excluir clipe
if (deleteClipBtn) {
deleteClipBtn.addEventListener('click', () => {
const clipId = appState.global.selectedClipId; // 1. Lê o estado
deleteClipBtn.addEventListener("click", () => {
initializeAudioContext();
const clipId = appState.global.selectedClipId;
if (clipId) {
if (removeAudioClip(clipId)) { // 2. Chama a função de state
appState.global.selectedClipId = null; // 3. Atualiza o estado global
renderAudioEditor(); // 4. Renderiza a mudança
sendAction({ type: "REMOVE_AUDIO_CLIP", clipId });
appState.global.selectedClipId = null;
}
}
// Esconde o menu
const menu = document.getElementById('timeline-context-menu');
if (menu) menu.style.display = 'none';
const menu = document.getElementById("timeline-context-menu");
if (menu) menu.style.display = "none";
});
}
// Listener global para a tecla Delete/Backspace
document.addEventListener('keydown', (e) => {
// Ignora se estiver digitando em um input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
// Delete/Backspace
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
const clipId = appState.global.selectedClipId;
if ((e.key === "Delete" || e.key === "Backspace") && clipId) {
e.preventDefault();
sendAction({ type: "REMOVE_AUDIO_CLIP", clipId });
appState.global.selectedClipId = null;
}
});
// Fechar menu contexto
document.addEventListener("click", (e) => {
const menu = document.getElementById("timeline-context-menu");
if (menu && !e.target.closest("#timeline-context-menu")) {
menu.style.display = "none";
}
});
// Ações principais (broadcast)
newProjectBtn?.addEventListener("click", () => {
initializeAudioContext();
if (
(appState.pattern.tracks.length > 0 || appState.audio.clips.length > 0) &&
!confirm(
"Você tem certeza? Isso irá resetar o projeto para TODOS na sala."
)
)
return;
}
const clipId = appState.global.selectedClipId; // 1. Lê o estado
// Verifica se há um clipe selecionado e a tecla pressionada é Delete ou Backspace
if ((e.key === 'Delete' || e.key === 'Backspace') && clipId) {
e.preventDefault(); // Impede o navegador de voltar a página (ação do Backspace)
if (removeAudioClip(clipId)) { // 2. Chama a função de state
appState.global.selectedClipId = null; // 3. Atualiza o estado global
renderAudioEditor(); // 4. Renderiza a mudança
}
}
sendAction({ type: "RESET_PROJECT" });
});
// Listener global para fechar menu de contexto ou desselecionar clipe
document.addEventListener('click', (e) => {
// Esconde o menu de contexto se clicar fora dele
const menu = document.getElementById('timeline-context-menu');
if (menu && !e.target.closest('#timeline-context-menu')) {
menu.style.display = 'none';
}
});
// --- FIM DOS LISTENERS ADICIONADOS ---
newProjectBtn.addEventListener("click", () => {
if ((appState.pattern.tracks.length > 0 || appState.audio.clips.length > 0) && !confirm("Você tem certeza? Alterações não salvas serão perdidas.")) return;
resetProjectState();
document.getElementById('bpm-input').value = 140;
document.getElementById('bars-input').value = 1;
document.getElementById('compasso-a-input').value = 4;
document.getElementById('compasso-b-input').value = 4;
const titleElement = document.getElementById('beat-bassline-title');
if(titleElement) titleElement.textContent = 'Novo Projeto';
renderAll();
});
addBarBtn.addEventListener("click", () => {
addBarBtn?.addEventListener("click", () => {
const barsInput = document.getElementById("bars-input");
if (barsInput) adjustValue(barsInput, 1);
if (barsInput) {
adjustValue(barsInput, 1);
barsInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
openMmpBtn.addEventListener("click", showOpenProjectModal);
loadFromComputerBtn.addEventListener("click", () => mmpFileInput.click());
mmpFileInput.addEventListener("change", (event) => { const file = event.target.files[0]; if (file) { handleFileLoad(file).then(() => closeOpenProjectModal()); } });
uploadSampleBtn.addEventListener("click", () => sampleFileInput.click());
saveMmpBtn.addEventListener("click", generateMmpFile);
addInstrumentBtn.addEventListener("click", addTrackToState);
removeInstrumentBtn.addEventListener("click", removeLastTrackFromState);
playBtn.addEventListener("click", togglePlayback);
stopBtn.addEventListener("click", stopPlayback);
rewindBtn.addEventListener("click", rewindPlayback);
metronomeBtn.addEventListener("click", () => { initializeAudioContext(); appState.global.metronomeEnabled = !appState.global.metronomeEnabled; metronomeBtn.classList.toggle("active", appState.global.metronomeEnabled); });
openMmpBtn?.addEventListener("click", showOpenProjectModal);
loadFromComputerBtn?.addEventListener("click", () => mmpFileInput?.click());
mmpFileInput?.addEventListener("change", (event) => {
const file = event.target.files[0];
if (file) handleFileLoad(file).then(() => closeOpenProjectModal());
});
uploadSampleBtn?.addEventListener("click", () => sampleFileInput?.click());
saveMmpBtn?.addEventListener("click", generateMmpFile);
// --- LISTENERS DE FERRAMENTAS ATUALIZADOS ---
addInstrumentBtn?.addEventListener("click", () => {
initializeAudioContext();
sendAction({ type: "ADD_TRACK" });
});
removeInstrumentBtn?.addEventListener("click", () => {
initializeAudioContext();
sendAction({ type: "REMOVE_LAST_TRACK" });
});
playBtn?.addEventListener("click", () => {
initializeAudioContext();
sendAction({ type: "TOGGLE_PLAYBACK" });
});
stopBtn?.addEventListener("click", () => {
initializeAudioContext();
sendAction({ type: "STOP_PLAYBACK" });
});
rewindBtn?.addEventListener("click", () => {
initializeAudioContext();
sendAction({ type: "REWIND_PLAYBACK" });
});
metronomeBtn?.addEventListener("click", () => {
initializeAudioContext();
appState.global.metronomeEnabled = !appState.global.metronomeEnabled;
metronomeBtn.classList.toggle("active", appState.global.metronomeEnabled);
});
// Ferramentas locais
if (sliceToolBtn) {
sliceToolBtn.addEventListener("click", () => {
appState.global.sliceToolActive = !appState.global.sliceToolActive;
@@ -165,26 +233,27 @@ document.addEventListener("DOMContentLoaded", () => {
}
if (resizeToolTrimBtn) {
resizeToolTrimBtn.addEventListener("click", () => {
appState.global.resizeMode = 'trim';
appState.global.resizeMode = "trim";
appState.global.sliceToolActive = false;
updateToolButtons();
});
}
if (resizeToolStretchBtn) {
resizeToolStretchBtn.addEventListener("click", () => {
appState.global.resizeMode = 'stretch';
appState.global.resizeMode = "stretch";
appState.global.sliceToolActive = false;
updateToolButtons();
});
}
openModalCloseBtn.addEventListener("click", closeOpenProjectModal);
sidebarToggle.addEventListener("click", () => {
openModalCloseBtn?.addEventListener("click", closeOpenProjectModal);
sidebarToggle?.addEventListener("click", () => {
document.body.classList.toggle("sidebar-hidden");
const icon = sidebarToggle.querySelector("i");
if (icon) {
icon.className = document.body.classList.contains("sidebar-hidden") ? "fa-solid fa-caret-right" : "fa-solid fa-caret-left";
icon.className = document.body.classList.contains("sidebar-hidden")
? "fa-solid fa-caret-right"
: "fa-solid fa-caret-left";
}
});
@@ -192,68 +261,180 @@ document.addEventListener("DOMContentLoaded", () => {
inputs.forEach((input) => {
input.addEventListener("input", (event) => {
enforceNumericInput(event);
if (appState.global.isPlaying && (event.target.id.startsWith("compasso-") || event.target.id === 'bars-input')) { stopPlayback(); }
if (event.target.id.startsWith("compasso-") || event.target.id === 'bars-input' || event.target.id === 'bpm-input') { renderAll(); }
if (
appState.global.isPlaying &&
(event.target.id.startsWith("compasso-") ||
event.target.id === "bars-input")
) {
sendAction({ type: "STOP_PLAYBACK" });
}
});
input.addEventListener("change", (event) => {
const target = event.target;
if (target.id === "bpm-input") {
sendAction({ type: "SET_BPM", value: target.value });
} else if (target.id === "bars-input") {
sendAction({ type: "SET_BARS", value: target.value });
} else if (target.id === "compasso-a-input") {
sendAction({ type: "SET_TIMESIG_A", value: target.value });
} else if (target.id === "compasso-b-input") {
sendAction({ type: "SET_TIMESIG_B", value: target.value });
}
});
input.addEventListener("wheel", (event) => {
event.preventDefault();
const step = event.deltaY < 0 ? 1 : -1;
adjustValue(event.target, step);
event.target.dispatchEvent(new Event("change", { bubbles: true }));
});
input.addEventListener("wheel", (event) => { event.preventDefault(); const step = event.deltaY < 0 ? 1 : -1; adjustValue(event.target, step); });
});
const buttons = document.querySelectorAll(".adjust-btn");
buttons.forEach((button) => { button.addEventListener("click", () => { const targetId = button.dataset.target + "-input"; const targetInput = document.getElementById(targetId); const step = parseInt(button.dataset.step, 10) || 1; if (targetInput) { adjustValue(targetInput, step); } }); });
buttons.forEach((button) => {
button.addEventListener("click", () => {
const targetId = button.dataset.target + "-input";
const targetInput = document.getElementById(targetId);
const step = parseInt(button.dataset.step, 10) || 1;
if (targetInput) {
adjustValue(targetInput, step);
targetInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
});
if (zoomInBtn) {
zoomInBtn.addEventListener("click", () => {
// Zoom local
zoomInBtn?.addEventListener("click", () => {
if (appState.global.zoomLevelIndex < ZOOM_LEVELS.length - 1) {
appState.global.zoomLevelIndex++;
renderAll();
}
});
}
if (zoomOutBtn) {
zoomOutBtn.addEventListener("click", () => {
zoomOutBtn?.addEventListener("click", () => {
if (appState.global.zoomLevelIndex > 0) {
appState.global.zoomLevelIndex--;
renderAll();
}
});
}
audioEditorPlayBtn.addEventListener("click", () => {
// Editor de Áudio
audioEditorPlayBtn?.addEventListener("click", () => {
initializeAudioContext();
if (appState.global.isAudioEditorPlaying) {
stopAudioEditorPlayback(false); // Pausa
sendAction({ type: "STOP_AUDIO_PLAYBACK", rewind: false });
} else {
startAudioEditorPlayback();
sendAction({
type: "START_AUDIO_PLAYBACK",
seekTime: appState.audio.audioEditorSeekTime, // Corrigido
loopState: {
isLoopActive: appState.global.isLoopActive,
loopStartTime: appState.global.loopStartTime,
loopEndTime: appState.global.loopEndTime,
},
});
}
});
audioEditorStopBtn.addEventListener("click", () => stopAudioEditorPlayback(true)); // Stop (rebobina)
audioEditorLoopBtn.addEventListener("click", () => {
appState.global.isLoopActive = !appState.global.isLoopActive;
appState.audio.isAudioEditorLoopEnabled = appState.global.isLoopActive;
audioEditorLoopBtn.classList.toggle("active", appState.global.isLoopActive);
updateTransportLoop();
const loopArea = document.getElementById("loop-region");
if (loopArea) {
loopArea.classList.toggle("visible", appState.global.isLoopActive);
}
restartAudioEditorIfPlaying();
audioEditorStopBtn?.addEventListener("click", () => {
initializeAudioContext();
sendAction({ type: "STOP_AUDIO_PLAYBACK", rewind: true });
});
if (addAudioTrackBtn) { addAudioTrackBtn.addEventListener("click", () => { addAudioTrackLane(); renderAudioEditor(); }); }
// Loop Button (agora envia ação)
audioEditorLoopBtn?.addEventListener("click", () => {
initializeAudioContext(); // Garante contexto
const newLoopState = !appState.global.isLoopActive;
sendAction({
type: "SET_LOOP_STATE",
isLoopActive: newLoopState,
loopStartTime: appState.global.loopStartTime,
loopEndTime: appState.global.loopEndTime,
});
});
if (addAudioTrackBtn) {
addAudioTrackBtn.addEventListener("click", () => {
initializeAudioContext();
sendAction({ type: "ADD_AUDIO_LANE" });
});
}
// Navegador de Samples (local)
loadAndRenderSampleBrowser();
const browserContent = document.getElementById('browser-content');
const browserContent = document.getElementById("browser-content");
if (browserContent) {
browserContent.addEventListener('click', function(event) {
const folderName = event.target.closest('.folder-name');
browserContent.addEventListener("click", function (event) {
const folderName = event.target.closest(".folder-name");
if (folderName) {
const folderItem = folderName.parentElement;
folderItem.classList.toggle('open');
folderItem.classList.toggle("open");
}
});
}
// Criar sala (gera link com ?room=...)
if (createRoomBtn) {
createRoomBtn.addEventListener("click", () => {
initializeAudioContext();
const currentParams = new URLSearchParams(window.location.search);
if (currentParams.has("room")) {
alert(
`Você já está na sala: ${currentParams.get(
"room"
)}\n\nCopie o link da barra de endereços para convidar.`
);
return;
}
const defaultName = `sessao-${Math.random()
.toString(36)
.substring(2, 7)}`;
const roomName = prompt(
"Digite um nome para a sala compartilhada:",
defaultName
);
if (!roomName) return;
const currentUrl = window.location.origin + window.location.pathname;
const shareableLink = `${currentUrl}?room=${encodeURIComponent(
roomName
)}`;
try {
navigator.clipboard.writeText(shareableLink);
alert(
`Link da sala copiado para a área de transferência!\n\n${shareableLink}\n\nA página será recarregada agora para entrar na nova sala.`
);
} catch (err) {
alert(
`Link da sala: ${shareableLink}\n\nA página será recarregada agora para entrar na nova sala.`
);
}
window.location.href = shareableLink;
});
}
// Modal “destravar áudio” + entrar na sala
const audioUnlockModal = document.getElementById("audio-unlock-modal");
const audioUnlockBtn = document.getElementById("audio-unlock-btn");
if (ROOM_NAME && audioUnlockModal && audioUnlockBtn) {
audioUnlockModal.style.display = "flex";
audioUnlockBtn.addEventListener("click", () => {
const userName = prompt(
"Qual o seu nome?",
`Alicer-${Math.floor(Math.random() * 999)}`
);
if (!userName) return;
setUserName(userName);
initializeAudioContext();
// joinRoom() já foi chamado no início se ROOM_NAME existe
audioUnlockModal.style.display = "none";
});
} else {
console.log("Modo local. Áudio será iniciado no primeiro clique.");
// Comentado para permitir teste visual
// if (syncModeBtn) syncModeBtn.style.display = "none";
}
renderAll();
updateToolButtons(); // Define o estado inicial dos botões
updateToolButtons();
});
+38 -13
View File
@@ -1,4 +1,6 @@
// js/pattern_audio.js
import * as Tone from "https://esm.sh/tone";
import { appState } from "../state.js";
import { highlightStep } from "./pattern_ui.js";
import { getTotalSteps } from "../utils.js";
@@ -21,21 +23,36 @@ export function playMetronomeSound(isDownbeat) {
synth.triggerAttackRelease(freq, "8n", Tone.now());
}
// --- FUNÇÃO CORRIGIDA E EFICIENTE ---
// Dispara o sample de uma track, garantindo que o player esteja roteado corretamente
export function playSample(filePath, trackId) {
initializeAudioContext();
const track = trackId ? appState.pattern.tracks.find((t) => t.id == trackId) : null;
// Se a faixa existe e tem um player pré-carregado, apenas o dispara.
// Se a faixa existe e tem um player pré-carregado
if (track && track.player) {
// Atualiza o volume/pan caso tenham sido alterados
track.gainNode.gain.value = Tone.gainToDb(track.volume);
track.pannerNode.pan.value = track.pan;
// Dispara o som imediatamente. Esta operação é instantânea.
track.player.start(Tone.now());
if (track.player.loaded) {
// Ajusta volume/pan sempre que tocar (robustez a alterações em tempo real)
if (track.volumeNode) {
track.volumeNode.volume.value =
track.volume === 0 ? -Infinity : Tone.gainToDb(track.volume);
}
// Fallback para preview de samples no navegador (sem trackId)
if (track.pannerNode) {
track.pannerNode.pan.value = track.pan ?? 0;
}
// Garante conexão: player -> volumeNode (não usar mais gainNode)
try { track.player.disconnect(); } catch {}
if (track.volumeNode) {
track.player.connect(track.volumeNode);
}
// Dispara imediatamente
track.player.start(Tone.now());
} else {
console.warn(`Player da trilha "${track.name}" ainda não carregado — pulando este tick.`);
}
}
// Fallback para preview de sample sem trackId
else if (!trackId && filePath) {
const previewPlayer = new Tone.Player(filePath).toDestination();
previewPlayer.autostart = true;
@@ -59,6 +76,7 @@ function tick() {
timerDisplay.textContent = formatTime(currentTime);
}
// Metrônomo
if (appState.global.metronomeEnabled) {
const noteValue = parseInt(document.getElementById("compasso-b-input").value, 10) || 4;
const stepsPerBeat = 16 / noteValue;
@@ -67,12 +85,16 @@ function tick() {
}
}
// Percorre tracks e toca o step atual se ativo
appState.pattern.tracks.forEach((track) => {
if (!track.patterns || track.patterns.length === 0) return;
const activePattern = track.patterns[appState.pattern.activePatternIndex];
// IMPORTANTE: usar o pattern ativo da PRÓPRIA TRILHA
const activePattern = track.patterns[track.activePatternIndex];
if (activePattern && activePattern.steps[appState.global.currentStep] && track.samplePath) {
if (activePattern &&
activePattern.steps[appState.global.currentStep] &&
track.samplePath) {
playSample(track.samplePath, track.id);
}
});
@@ -96,8 +118,11 @@ export function startPlayback() {
if (appState.global.playbackIntervalId) clearInterval(appState.global.playbackIntervalId);
appState.global.isPlaying = true;
document.getElementById("play-btn").classList.remove("fa-play");
document.getElementById("play-btn").classList.add("fa-pause");
const playBtn = document.getElementById("play-btn");
if (playBtn) {
playBtn.classList.remove("fa-play");
playBtn.classList.add("fa-pause");
}
tick();
appState.global.playbackIntervalId = setInterval(tick, stepInterval);
@@ -0,0 +1,108 @@
// js/pattern/pattern_bounce.js
import { appState } from '../state.js';
import { getSecondsPerStep } from '../utils.js';
import { addAudioClipToTimeline, addAudioTrackLane } from '../audio/audio_state.js';
import { getAudioContext } from '../audio.js';
import { renderAudioEditor } from '../audio/audio_ui.js';
/**
* Renderiza (bounce) o pattern de beat atualmente ativo para uma nova pista de áudio.
*/
export async function bounceActivePatternToAudio() {
console.log("Iniciando 'bounce' do pattern...");
// 1. Encontrar o pattern ativo
const activeTrackId = appState.pattern.activeTrackId;
// --- DEBUG ---
console.log(`[DEBUG bounce] activeTrackId lido do estado: ${activeTrackId}`);
const activeTrack = appState.pattern.tracks.find(t => t.id === activeTrackId);
// --- DEBUG ---
if (activeTrack) {
console.log(`[DEBUG bounce] Pista ativa encontrada:`, activeTrack.name);
console.log(`[DEBUG bounce] Verificando track.buffer:`, activeTrack.buffer);
} else {
console.error(`[DEBUG bounce] NENHUMA PISTA ATIVA ENCONTRADA. activeTrackId é nulo ou inválido.`);
console.log(`[DEBUG bounce] Pistas disponíveis no estado:`, appState.pattern.tracks.map(t => ({id: t.id, name: t.name})));
}
// --- FIM DEBUG ---
if (!activeTrack) {
alert('Nenhuma pista de pattern selecionada para renderizar.');
return;
}
const activePattern = activeTrack.patterns[activeTrack.activePatternIndex];
if (!activePattern) {
alert('Nenhum pattern ativo encontrado na pista.');
return;
}
const trackBuffer = activeTrack.buffer;
if (!trackBuffer) {
alert('O áudio (sample) desta pista ainda não foi carregado.');
return;
}
// 2. Calcular a duração do pattern
const steps = activePattern.steps;
const totalSteps = steps.length;
const secondsPerStep = getSecondsPerStep(); //
const duration = totalSteps * secondsPerStep;
if (duration <= 0) {
alert("Pattern está vazio ou com duração zero.");
return;
}
try {
// 3. Usar Tone.Offline para renderizar o áudio
const audioBuffer = await Tone.Offline(async (offlineCtx) => {
const gainNode = new Tone.Gain(Tone.gainToDb(activeTrack.volume)).connect(offlineCtx.destination);
const pannerNode = new Tone.Panner(activeTrack.pan).connect(gainNode); //
const now = offlineCtx.currentTime;
// --- INÍCIO DA CORREÇÃO (que estava na versão anterior) ---
const offlineBuffer = offlineCtx.createBuffer(
trackBuffer.numberOfChannels,
trackBuffer.length,
trackBuffer.sampleRate
);
for (let i = 0; i < trackBuffer.numberOfChannels; i++) {
offlineBuffer.copyToChannel(trackBuffer.getChannelData(i), i);
}
// --- FIM DA CORREÇÃO ---
// Agendar todas as notas (steps)
steps.forEach((isActive, index) => {
if (isActive) {
const time = now + (index * secondsPerStep);
const source = new Tone.BufferSource(offlineBuffer).connect(pannerNode);
source.start(time);
}
});
}, duration);
// 4. Áudio renderizado. Agora, adicione-o ao editor de áudio.
addAudioTrackLane(); //
const newTrack = appState.audio.tracks[appState.audio.tracks.length - 1]; //
const newTrackId = newTrack.id;
const clipName = `${activeTrack.name}_(${activePattern.name})`;
addAudioClipToTimeline(null, newTrackId, 0, clipName, audioBuffer); //
console.log("Pattern renderizado com sucesso!");
} catch (err) {
console.error("Erro ao renderizar o pattern:", err);
alert(`Erro ao renderizar pattern: ${err.message}`);
}
}
+94 -43
View File
@@ -1,93 +1,144 @@
// js/pattern_state.js
import * as Tone from "https://esm.sh/tone";
import { appState } from "../state.js";
import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js";
import { getAudioContext, getMainGainNode } from "../audio.js";
import { renderPatternEditor } from "./pattern_ui.js";
import { getMainGainNode } from "../audio.js";
import { getTotalSteps } from "../utils.js";
const initialState = {
tracks: [],
activeTrackId: null,
activePatternIndex: 0,
};
export let patternState = { ...initialState };
export function initializePatternState() {
Object.assign(patternState, initialState, { tracks: [] });
// Limpa players/buffers existentes
appState.pattern.tracks.forEach(track => {
try { track.player?.dispose(); } catch {}
try { track.buffer?.dispose?.(); } catch {}
});
// Reseta estado do editor de pattern
appState.pattern.tracks = [];
appState.pattern.activeTrackId = null;
appState.pattern.activePatternIndex = 0;
}
// --- FUNÇÃO CORRIGIDA ---
// Agora, esta função cria e pré-carrega um Tone.Player para a faixa.
export async function loadAudioForTrack(track) {
if (!track.samplePath) return track;
try {
// Se já existir um player antigo, o descartamos para liberar memória.
if (track.player) {
track.player.dispose();
// Descartar player/buffer anteriores com segurança
try { track.player?.dispose(); } catch {}
track.player = null;
try { track.buffer?.dispose?.(); } catch {}
track.buffer = null;
// Garante nós de volume/pan (Opção B: Volume em dB)
if (!track.volumeNode) {
track.volumeNode = new Tone.Volume(
track.volume === 0 ? -Infinity : Tone.gainToDb(track.volume)
);
} else {
track.volumeNode.volume.value =
track.volume === 0 ? -Infinity : Tone.gainToDb(track.volume);
}
// Cria um novo Tone.Player e o conecta à cadeia de áudio da faixa.
// O 'await' garante que o áudio seja totalmente carregado antes de prosseguirmos.
track.player = await new Tone.Player(track.samplePath).toDestination();
track.player.chain(track.gainNode, track.pannerNode, getMainGainNode());
if (!track.pannerNode) {
track.pannerNode = new Tone.Panner(track.pan ?? 0);
} else {
track.pannerNode.pan.value = track.pan ?? 0;
}
// Encadeia: Volume(dB) -> Panner -> Master
try { track.volumeNode.disconnect(); } catch {}
try { track.pannerNode.disconnect(); } catch {}
track.volumeNode.connect(track.pannerNode);
track.pannerNode.connect(getMainGainNode());
// Cria e carrega o Player
const player = new Tone.Player({ url: track.samplePath, autostart: false });
await player.load(track.samplePath); // garante buffer carregado
// Conecta o player ao volumeNode
player.connect(track.volumeNode);
// Buffer separado (se você usar waveform em outro lugar)
const buffer = new Tone.Buffer();
await buffer.load(track.samplePath);
// Atribuições finais
track.player = player;
track.buffer = buffer;
} catch (error) {
console.error(`Falha ao carregar áudio para a trilha ${track.name}:`, error);
console.error('Erro ao carregar sample:', track.samplePath);
console.error(`Falha ao carregar áudio para a trilha "${track.name}":`, error);
try { track.player?.dispose(); } catch {}
try { track.buffer?.dispose?.(); } catch {}
track.player = null;
track.buffer = null;
}
return track;
}
export function addTrackToState() {
const mainGainNode = getMainGainNode();
const totalSteps = getTotalSteps();
const referenceTrack = patternState.tracks[0];
const referenceTrack = appState.pattern.tracks[0];
const newTrack = {
id: Date.now(),
id: Date.now() + Math.random(),
name: "novo instrumento",
samplePath: null,
player: null, // <-- ADICIONADO: O player começará como nulo
player: null,
buffer: null,
patterns: referenceTrack
? referenceTrack.patterns.map(p => ({ name: p.name, steps: new Array(p.steps.length).fill(false), pos: p.pos }))
? referenceTrack.patterns.map(p => ({
name: p.name,
steps: new Array(p.steps.length).fill(false),
pos: p.pos
}))
: [{ name: "Pattern 1", steps: new Array(totalSteps).fill(false), pos: 0 }],
activePatternIndex: 0,
volume: DEFAULT_VOLUME,
pan: DEFAULT_PAN,
gainNode: new Tone.Gain(Tone.gainToDb(DEFAULT_VOLUME)),
// Opção B: controlar volume em dB
volumeNode: new Tone.Volume(Tone.gainToDb(DEFAULT_VOLUME)),
pannerNode: new Tone.Panner(DEFAULT_PAN),
};
newTrack.gainNode.chain(newTrack.pannerNode, mainGainNode);
// Cadeia de áudio nova
newTrack.volumeNode.connect(newTrack.pannerNode);
newTrack.pannerNode.connect(getMainGainNode());
patternState.tracks.push(newTrack);
renderPatternEditor();
appState.pattern.tracks.push(newTrack);
appState.pattern.activeTrackId = newTrack.id;
}
export function removeLastTrackFromState() {
if (patternState.tracks.length > 0) {
const trackToRemove = patternState.tracks[patternState.tracks.length - 1];
if (trackToRemove.player) trackToRemove.player.dispose();
if (trackToRemove.pannerNode) trackToRemove.pannerNode.dispose();
if (trackToRemove.gainNode) trackToRemove.gainNode.dispose();
if (appState.pattern.tracks.length > 0) {
const trackToRemove = appState.pattern.tracks[appState.pattern.tracks.length - 1];
patternState.tracks.pop();
renderPatternEditor();
try { trackToRemove.player?.dispose(); } catch {}
try { trackToRemove.buffer?.dispose?.(); } catch {}
try { trackToRemove.pannerNode?.disconnect(); } catch {}
try { trackToRemove.volumeNode?.disconnect(); } catch {}
appState.pattern.tracks.pop();
if (appState.pattern.activeTrackId === trackToRemove.id) {
appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id ?? null;
}
}
}
export async function updateTrackSample(trackId, samplePath) {
const track = patternState.tracks.find((t) => t.id == trackId);
export async function updateTrackSample(trackIndex, samplePath) {
const track = appState.pattern.tracks[trackIndex];
if (track) {
track.samplePath = samplePath;
track.name = samplePath.split("/").pop();
await loadAudioForTrack(track); // Carrega o novo player
renderPatternEditor();
// (re)carrega e reconecta corretamente o player nesta trilha
await loadAudioForTrack(track);
}
}
export function toggleStepState(trackId, stepIndex) {
const track = patternState.tracks.find((t) => t.id == trackId);
export function toggleStepState(trackIndex, stepIndex) {
const track = appState.pattern.tracks[trackIndex];
if (track && track.patterns && track.patterns.length > 0) {
const activePattern = track.patterns[track.activePatternIndex];
if (activePattern && activePattern.steps.length > stepIndex) {
+180 -133
View File
@@ -1,171 +1,218 @@
// js/pattern_ui.js
import { appState } from "../state.js";
import {
toggleStepState,
updateTrackSample
    updateTrackSample
} from "./pattern_state.js";
import { playSample, stopPlayback } from "./pattern_audio.js"; // Será criado no próximo passo
import { playSample, stopPlayback } from "./pattern_audio.js";
import { getTotalSteps } from "../utils.js";
import { sendAction } from '../socket.js';
import { initializeAudioContext } from '../audio.js';
// Função principal de renderização para o editor de patterns
export function renderPatternEditor() {
const trackContainer = document.getElementById("track-container");
trackContainer.innerHTML = "";
  const trackContainer = document.getElementById("track-container");
  trackContainer.innerHTML = "";
appState.pattern.tracks.forEach((trackData) => {
const trackLane = document.createElement("div");
trackLane.className = "track-lane";
trackLane.dataset.trackId = trackData.id;
// (V7) Adicionado 'trackIndex'
  appState.pattern.tracks.forEach((trackData, trackIndex) => {
    const trackLane = document.createElement("div");
    trackLane.className = "track-lane";
    trackLane.dataset.trackIndex = trackIndex; // (V7) Usando índice
if (trackData.id === appState.pattern.activeTrackId) {
trackLane.classList.add('active-track');
}
    if (trackData.id === appState.pattern.activeTrackId) {
        trackLane.classList.add('active-track');
    }
trackLane.innerHTML = `
<div class="track-info">
<i class="fa-solid fa-gear"></i>
<div class="track-mute"></div>
<span class="track-name">${trackData.name}</span>
</div>
<div class="track-controls">
<div class="knob-container">
<div class="knob" data-control="volume" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
<span>VOL</span>
</div>
<div class="knob-container">
<div class="knob" data-control="pan" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
<span>PAN</span>
</div>
</div>
<div class="step-sequencer-wrapper"></div>
`;
    trackLane.innerHTML = `
      <div class="track-info">
        <i class="fa-solid fa-gear"></i>
        <div class="track-mute"></div>
        <span class="track-name">${trackData.name}</span>
      </div>
      <div class="track-controls">
        <div class="knob-container">
          <div class="knob" data-control="volume" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
          <span>VOL</span>
        </div>
        <div class="knob-container">
          <div class="knob" data-control="pan" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
          <span>PAN</span>
        </div>
      </div>
      <div class="step-sequencer-wrapper"></div>
    `;
trackLane.addEventListener('click', () => {
if (appState.pattern.activeTrackId === trackData.id) return;
stopPlayback();
appState.pattern.activeTrackId = trackData.id;
document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track'));
trackLane.classList.add('active-track');
updateGlobalPatternSelector();
redrawSequencer();
// (Listener de clique da track é local, sem mudanças)
    trackLane.addEventListener('click', () => {
        if (appState.pattern.activeTrackId === trackData.id) return;
        stopPlayback();
        appState.pattern.activeTrackId = trackData.id;
        document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track'));
        trackLane.classList.add('active-track');
        updateGlobalPatternSelector();
        redrawSequencer();
    });
    trackLane.addEventListener("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); });
    trackLane.addEventListener("dragleave", () => trackLane.classList.remove("drag-over"));
// (V9) Listener de "drop" (arrastar) agora usa 'sendAction'
    trackLane.addEventListener("drop", (e) => {
      e.preventDefault();
      trackLane.classList.remove("drag-over");
      const filePath = e.dataTransfer.getData("text/plain");
     
      if (filePath) {
sendAction({
type: 'SET_TRACK_SAMPLE',
trackIndex: trackIndex,
filePath: filePath
});
      }
    });
trackLane.addEventListener("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); });
trackLane.addEventListener("dragleave", () => trackLane.classList.remove("drag-over"));
trackLane.addEventListener("drop", (e) => {
e.preventDefault();
trackLane.classList.remove("drag-over");
const filePath = e.dataTransfer.getData("text/plain");
if (filePath) {
updateTrackSample(trackData.id, filePath);
}
});
trackContainer.appendChild(trackLane);
// A lógica dos knobs precisará ser reimplementada ou movida para um arquivo de componentes
});
updateGlobalPatternSelector();
redrawSequencer();
    trackContainer.appendChild(trackLane);
  });
 
  updateGlobalPatternSelector();
  redrawSequencer();
}
export function redrawSequencer() {
const totalGridSteps = getTotalSteps();
document.querySelectorAll(".step-sequencer-wrapper").forEach((wrapper) => {
let sequencerContainer = wrapper.querySelector(".step-sequencer");
if (!sequencerContainer) {
sequencerContainer = document.createElement("div");
sequencerContainer.className = "step-sequencer";
wrapper.appendChild(sequencerContainer);
}
  const totalGridSteps = getTotalSteps();
  document.querySelectorAll(".step-sequencer-wrapper").forEach((wrapper) => {
    let sequencerContainer = wrapper.querySelector(".step-sequencer");
    if (!sequencerContainer) {
      sequencerContainer = document.createElement("div");
      sequencerContainer.className = "step-sequencer";
      wrapper.appendChild(sequencerContainer);
    }
   
    const parentTrackElement = wrapper.closest(".track-lane");
    const trackIndex = parseInt(parentTrackElement.dataset.trackIndex, 10); // (V7)
    // ... dentro da função redrawSequencer() ...
const parentTrackElement = wrapper.closest(".track-lane");
const trackId = parentTrackElement.dataset.trackId;
const trackData = appState.pattern.tracks.find((t) => t.id == trackId);
    const trackData = appState.pattern.tracks[trackIndex];
if (!trackData || !trackData.patterns || trackData.patterns.length === 0) {
sequencerContainer.innerHTML = ""; return;
}
    if (!trackData || !trackData.patterns || trackData.patterns.length === 0) {
      sequencerContainer.innerHTML = ""; return;
    }
const activePattern = trackData.patterns[appState.pattern.activePatternIndex];
if (!activePattern) {
sequencerContainer.innerHTML = ""; return;
}
const patternSteps = activePattern.steps;
// --- CORRIJA ESTAS DUAS LINHAS ---
// ANTES:
// const activePatternIndex = appState.pattern.activePatternIndex;
// const activePattern = trackData.patterns[activePatternIndex];
//
// DEPOIS:
    const activePatternIndex = trackData.activePatternIndex;
    const activePattern = trackData.patterns[activePatternIndex];
sequencerContainer.innerHTML = "";
for (let i = 0; i < totalGridSteps; i++) {
const stepWrapper = document.createElement("div");
stepWrapper.className = "step-wrapper";
const stepElement = document.createElement("div");
stepElement.className = "step";
    if (!activePattern) {
        sequencerContainer.innerHTML = ""; return;
    }
// ... resto da função ...
    const patternSteps = activePattern.steps;
if (patternSteps[i] === true) {
stepElement.classList.add("active");
}
    sequencerContainer.innerHTML = "";
    for (let i = 0; i < totalGridSteps; i++) {
      const stepWrapper = document.createElement("div");
      stepWrapper.className = "step-wrapper";
      const stepElement = document.createElement("div");
      stepElement.className = "step";
     
      if (patternSteps[i] === true) {
        stepElement.classList.add("active");
      }
stepElement.addEventListener("click", () => {
toggleStepState(trackData.id, i);
stepElement.classList.toggle("active");
if (trackData && trackData.samplePath) {
playSample(trackData.samplePath, trackData.id);
}
      stepElement.addEventListener("click", () => {
initializeAudioContext(); // (V8)
const currentState = activePattern.steps[i] || false;
const isActive = !currentState;
sendAction({ // (V7)
type: 'TOGGLE_NOTE',
trackIndex: trackIndex,
patternIndex: activePatternIndex,
stepIndex: i,
isActive: isActive
});
const beatsPerBar = parseInt(document.getElementById("compasso-a-input").value, 10) || 4;
const groupIndex = Math.floor(i / beatsPerBar);
if (groupIndex % 2 === 0) {
stepElement.classList.add("step-dark");
}
        if (isActive && trackData && trackData.samplePath) {
          playSample(trackData.samplePath, trackData.id);
        }
      });
const stepsPerBar = 16;
if (i > 0 && i % stepsPerBar === 0) {
const marker = document.createElement("div");
marker.className = "step-marker";
marker.textContent = Math.floor(i / stepsPerBar) + 1;
stepWrapper.appendChild(marker);
}
      const beatsPerBar = parseInt(document.getElementById("compasso-a-input").value, 10) || 4;
      const groupIndex = Math.floor(i / beatsPerBar);
      if (groupIndex % 2 === 0) {
        stepElement.classList.add("step-dark");
      }
stepWrapper.appendChild(stepElement);
sequencerContainer.appendChild(stepWrapper);
}
});
      const stepsPerBar = 16;
      if (i > 0 && i % stepsPerBar === 0) {
        const marker = document.createElement("div");
        marker.className = "step-marker";
        marker.textContent = Math.floor(i / stepsPerBar) + 1;
        stepWrapper.appendChild(marker);
      }
     
      stepWrapper.appendChild(stepElement);
      sequencerContainer.appendChild(stepWrapper);
    }
  });
}
export function updateGlobalPatternSelector() {
const globalPatternSelector = document.getElementById('global-pattern-selector');
if (!globalPatternSelector) return;
    const globalPatternSelector = document.getElementById('global-pattern-selector');
    if (!globalPatternSelector) return;
const referenceTrack = appState.pattern.tracks[0];
globalPatternSelector.innerHTML = '';
if (referenceTrack && referenceTrack.patterns.length > 0) {
referenceTrack.patterns.forEach((pattern, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = pattern.name;
globalPatternSelector.appendChild(option);
});
globalPatternSelector.selectedIndex = appState.pattern.activePatternIndex;
globalPatternSelector.disabled = false;
} else {
const option = document.createElement('option');
option.textContent = 'Sem patterns';
globalPatternSelector.appendChild(option);
globalPatternSelector.disabled = true;
}
    const referenceTrack = appState.pattern.tracks[0];
    globalPatternSelector.innerHTML = '';
    if (referenceTrack && referenceTrack.patterns.length > 0) {
        referenceTrack.patterns.forEach((pattern, index) => {
            const option = document.createElement('option');
            option.value = index;
            option.textContent = pattern.name;
            globalPatternSelector.appendChild(option);
        });
        globalPatternSelector.selectedIndex = appState.pattern.activePatternIndex;
        globalPatternSelector.disabled = false;
    } else {
        const option = document.createElement('option');
        option.textContent = 'Sem patterns';
        globalPatternSelector.appendChild(option);
        globalPatternSelector.disabled = true;
    }
}
export function highlightStep(stepIndex, isActive) {
if (stepIndex < 0) return;
document.querySelectorAll(".track-lane").forEach((track) => {
const stepWrapper = track.querySelector(
  if (stepIndex < 0) return;
  document.querySelectorAll(".track-lane").forEach((track) => {
    const stepWrapper = track.querySelector(
      `.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})`
    );
    if (stepWrapper) {
      const stepElement = stepWrapper.querySelector(".step");
      if (stepElement) {
        stepElement.classList.toggle("playing", isActive);
      }
    }
  });
}
// (V7) Função de UI "cirúrgica"
export function updateStepUI(trackIndex, patternIndex, stepIndex, isActive) {
if (patternIndex !== appState.pattern.activePatternIndex) {
return;
}
const trackElement = document.querySelector(`.track-lane[data-track-index="${trackIndex}"]`);
if (!trackElement) return;
const stepWrapper = trackElement.querySelector(
`.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})`
);
if (stepWrapper) {
if (!stepWrapper) return;
const stepElement = stepWrapper.querySelector(".step");
if (stepElement) {
stepElement.classList.toggle("playing", isActive);
}
}
});
if (!stepElement) return;
stepElement.classList.toggle("active", isActive);
}
+4 -7
View File
@@ -145,7 +145,7 @@ async function _startRecording() {
} catch (err) {
console.error("Erro ao iniciar a gravação:", err);
appState.global.isRecording = false; //
_updateRecordButtonUI(false); //
_updateRecordButtonUI(false);
}
}
@@ -188,7 +188,7 @@ async function _stopRecording() {
} catch (err) {
console.error("Erro ao parar a gravação:", err);
appState.global.isRecording = false; //
_updateRecordButtonUI(false); //
_updateRecordButtonUI(false);
}
}
@@ -215,12 +215,9 @@ function _processRecording(blob) {
const clipName = `Rec_${new Date().toISOString().slice(11, 19).replace(/:/g, '-')}`;
// Adiciona o clipe à pista que já criamos
addAudioClipToTimeline(blobUrl, targetTrackId, 0, clipName); //
addAudioClipToTimeline(blobUrl, targetTrackId, 0, clipName, null); //
// addAudioClipToTimeline já chama o render, mas como o estado mudou
// (o clipe foi adicionado), renderizar de novo garante que o
// waveform *final* (do blob) seja desenhado corretamente.
renderAudioEditor(); //
// addAudioClipToTimeline já chama o render
}
/**
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^5.1.0",
"pino": "^8.21.0",
"socket.io": "^4.8.1",
"tone": "^15.1.22"
}
}
+514
View File
@@ -0,0 +1,514 @@
// server.js - VERSÃO 6 (ACK + Clock Sync + Logs Dinâmicos + Persistência Autoritativa)
// =========================
// IMPORTS PRINCIPAIS
// =========================
const express = require("express");
const { createServer } = require("http");
const { Server } = require("socket.io");
const fs = require("fs");
const path = require("path");
const pino = require("pino");
// -------------------------
// LOGGER DINÂMICO (GERENCIADOR)
// -------------------------
// Vamos manter um mapa de loggers ativos, um para cada sala
const loggersByRoom = new Map();
// Função para gerar um timestamp formatado para nome de arquivo
function getTimestampForFile() {
const d = new Date();
const date = d.toISOString().split("T")[0]; // YYYY-MM-DD
// HH-MM-SS (formato 24h, fuso local)
const time = d.toTimeString().split(" ")[0].replace(/:/g, "-");
return `${date}_${time}`; // ex: 2025-10-26_12-30-05
}
// Função principal: Pega o logger de uma sala ou cria um novo
function getActionLoggerForRoom(roomName) {
// 1. Se já criamos um logger para esta sala, reutilize-o
if (loggersByRoom.has(roomName)) {
return loggersByRoom.get(roomName);
}
// 2. Se for a primeira vez, crie um novo logger
const timestamp = getTimestampForFile();
// Limpa o nome da sala para evitar caracteres inválidos no nome do arquivo
const safeRoomName = roomName.replace(/[^a-z0-9_-]/gi, "_");
const fileName = `${timestamp}_${safeRoomName}.log`;
const filePath = path.join(process.cwd(), "data", fileName);
console.log(`[Logger] Novo log de sessão iniciado: ${filePath}`);
// Garante que a pasta 'data' exista
fs.mkdirSync(path.join(process.cwd(), "data"), { recursive: true });
// Cria a instância do pino para este arquivo específico
const newLogger = pino(pino.destination(filePath));
// Armazena no mapa para reutilização
loggersByRoom.set(roomName, newLogger);
return newLogger;
}
// -------------------------
// Configuração do Servidor
// -------------------------
const app = express();
const httpServer = createServer(app);
const PORT = process.env.PORT || 33007;
// -------------------------
// Persistência por sala
// -------------------------
/*
Estrutura na memória:
roomStates = {
[roomName]: {
projectXml: '<xml...>' | undefined,
audio: { tracks: [], clips: [] },
seq: number,
tokensSeen: Set<string> // idempotência
}
}
*/
const roomStates = {}; // compat com V4 (já existia), ampliado para incluir audio/seq
function dataFile(roomName) {
return path.join(process.cwd(), "data", `${roomName}.json`);
}
function ensureRoom(roomName) {
if (roomStates[roomName]) return roomStates[roomName];
// tenta carregar do disco
try {
const p = dataFile(roomName);
if (fs.existsSync(p)) {
const j = JSON.parse(fs.readFileSync(p, "utf8"));
roomStates[roomName] = {
projectXml: j.projectXml || null,
audio: j.audio || { tracks: [], clips: [] },
seq: j.seq || 0,
tokensSeen: new Set(),
};
return roomStates[roomName];
}
} catch (e) {
console.warn(`[persist] falha ao carregar estado da sala ${roomName}:`, e);
}
roomStates[roomName] = {
projectXml: null,
audio: { tracks: [], clips: [] },
seq: 0,
tokensSeen: new Set(),
};
return roomStates[roomName];
}
function saveRoom(roomName) {
const r = ensureRoom(roomName);
try {
fs.mkdirSync(path.join(process.cwd(), "data"), { recursive: true });
fs.writeFileSync(
dataFile(roomName),
JSON.stringify({ projectXml: r.projectXml, audio: r.audio, seq: r.seq })
);
} catch (e) {
console.warn(`[persist] falha ao salvar estado da sala ${roomName}:`, e);
}
}
// -------------------------
// Server / IO
// -------------------------
const io = new Server(httpServer, {
cors: {
origin: [
"https://alice.ufsj.edu.br",
"http://127.0.0.1:33007",
"http://localhost:33007",
"http://localhost:5173",
"http://127.0.0.1:5173",
],
methods: ["GET", "POST"],
credentials: true,
},
});
console.log("Backend V6 (Logs Dinâmicos) iniciado. Aguardando conexões...");
// util de log das salas deste socket
function logMyRooms(socket, prefix = "SALAS") {
const myRooms = Array.from(socket.rooms || []);
console.log(`[${prefix}] socket ${socket.id} está em:`, myRooms);
}
// -------------------------
// REDUCER autoritativo do editor de áudio
// -------------------------
function basenameNoExt(p) {
if (!p) return "";
const base = String(p).split(/[\\/]/).pop();
return base.replace(/\.[^/.]+$/, "");
}
function applyAuthoritativeAction(roomName, action) {
const room = ensureRoom(roomName);
const state = room.audio;
// idempotência por token (se fornecido)
if (action.__token && room.tokensSeen.has(action.__token)) {
return null;
}
if (action.__token) room.tokensSeen.add(action.__token);
let mutated = false;
switch (action.type) {
case "ADD_AUDIO_LANE": {
// Usa o trackId enviado pelo cliente, se existir
const id = action.trackId || generateUniqueId("track_server");
if (!state.tracks.find((t) => t.id === id)) {
state.tracks.push({
id,
name: `Pista de Áudio ${state.tracks.length + 1}`,
});
mutated = true;
}
break;
}
case "ADD_AUDIO_CLIP": {
const { clipId, filePath, trackId, startTimeInSeconds, name } = action;
// Blindagem do Servidor (Evitar faixas fantasma)
if (
!trackId ||
clipId == null ||
startTimeInSeconds == null ||
isNaN(startTimeInSeconds)
) {
console.warn(
`[Server] Ação ADD_AUDIO_CLIP rejeitada (dados inválidos):`,
action
);
return null; // Rejeita a ação, não faz broadcast
}
if (!state.tracks.find((t) => t.id === trackId)) {
state.tracks.push({
id: trackId,
name: `Pista de Áudio ${state.tracks.length + 1}`,
});
mutated = true;
}
if (!state.clips.find((c) => String(c.id) === String(clipId))) {
state.clips.push({
id: clipId,
trackId,
name: name || basenameNoExt(filePath) || "sample",
sourcePath: filePath || null, // ideal: URL pública
startTimeInSeconds,
durationInSeconds: action.durationInSeconds || 0,
offset: action.offset || 0,
pitch: action.pitch || 0,
volume: action.volume ?? 1,
pan: action.pan ?? 0,
originalDuration: action.originalDuration || 0,
});
mutated = true;
}
break;
}
case "UPDATE_AUDIO_CLIP": {
// =================================================================
// 👇 INÍCIO DA CORREÇÃO (Blindagem do Servidor para Bug 4)
// =================================================================
if (!action.clipId || !action.props) {
console.warn(
`[Server] Ação UPDATE_AUDIO_CLIP rejeitada (dados base inválidos):`,
action
);
return null;
}
const { trackId, startTimeInSeconds } = action.props;
if (
trackId !== undefined &&
(trackId == null || (typeof trackId === "number" && isNaN(trackId)))
) {
console.warn(
`[Server] Ação UPDATE_AUDIO_CLIP rejeitada (trackId inválido):`,
action
);
return null;
}
if (
startTimeInSeconds !== undefined &&
(startTimeInSeconds == null || isNaN(startTimeInSeconds))
) {
console.warn(
`[Server] Ação UPDATE_AUDIO_CLIP rejeitada (startTimeInSeconds inválido):`,
action
);
return null;
}
// =================================================================
// 👆 FIM DA CORREÇÃO
// =================================================================
const c = state.clips.find((x) => String(x.id) === String(action.clipId));
if (c && action.props && typeof action.props === "object") {
Object.assign(c, action.props);
mutated = true;
}
break;
}
case "REMOVE_AUDIO_CLIP": {
const i = state.clips.findIndex(
(x) => String(x.id) === String(action.clipId)
);
if (i >= 0) {
state.clips.splice(i, 1);
mutated = true;
}
break;
}
default:
// outras ações não são da persistência do editor de áudio
mutated = false;
}
if (!mutated) return null;
room.seq += 1; // numeração autoritativa
saveRoom(roomName); // persistência em disco
return { ...action, __seq: room.seq }; // devolve ação com seq para broadcast
}
io.on("connection", (socket) => {
console.log(`Novo usuário conectado: ${socket.id}`);
// =======================================
// JOIN ROOM
// =======================================
socket.on("join_room", async (data) => {
const { roomName, userName } = data || {};
if (!roomName) {
console.warn(`join_room inválido de ${socket.id} (sem roomName)`);
return;
}
await socket.join(roomName);
console.log(
`Usuário ${userName || "(sem nome)"} (${
socket.id
}) entrou na sala: ${roomName}`
);
logMyRooms(socket, "JOIN");
// envia estado salvo do projeto (XML) — compat V4
const room = ensureRoom(roomName);
if (room.projectXml) {
socket.emit("load_project_state", room.projectXml);
console.log(
`Estado XML enviado para ${userName || socket.id} na sala ${roomName}`
);
} else {
console.log(`Sala ${roomName} sem XML salvo ainda.`);
}
// envia snapshot autoritativo do editor de áudio
socket.emit("action_broadcast", {
action: {
type: "AUDIO_SNAPSHOT",
snapshot: room.audio,
__seq: room.seq,
__target: socket.id,
},
});
socket
.to(roomName)
.emit("feedback", `Usuário ${userName || socket.id} entrou na sala.`);
});
// =======================================
// CLOCK SYNC
// =======================================
socket.on("what_time_is_it", (_data, cb) => {
cb && cb({ serverNowMs: Date.now() });
});
// =======================================
// BROADCAST COM ACK + PERSISTÊNCIA
// =======================================
socket.on("broadcast_action", async (payload, cb) => {
const { roomName, action } = payload || {};
if (!roomName || !action) {
cb && cb({ ok: false, error: "invalid_payload" });
console.warn(
`[broadcast_action] payload inválido de ${socket.id}:`,
payload
);
return;
}
// 👇 *** NOSSO NOVO LOG DINÂMICO *** 👇
try {
// 1. Pega o logger específico para ESTA sala
const roomLogger = getActionLoggerForRoom(roomName);
// 2. Loga a ação (não precisamos mais salvar roomName, já está no nome do arq.)
roomLogger.info(
{
timestamp: Date.now(),
socketId: socket.id,
action: action,
},
"action_received"
);
} catch (e) {
console.warn(`[Logger] Falha ao logar ação para sala ${roomName}:`, e);
}
// *** FIM DO LOG ***
// Confirma recebimento imediatamente
cb && cb({ ok: true, token: action.__token });
// Garante que o emissor está na sala (caso o join tenha falhado/atrasado)
if (!socket.rooms.has(roomName)) {
console.warn(
`[broadcast_action] ${socket.id} NÃO estava na sala ${roomName}. Forçando join...`
);
await socket.join(roomName);
logMyRooms(socket, "FORCED_JOIN");
}
// Carrega estado da sala
const room = ensureRoom(roomName);
// Persiste estado se for LOAD_PROJECT (compat)
if (action.type === "LOAD_PROJECT" && action.xml) {
room.projectXml = action.xml;
saveRoom(roomName);
console.log(
`[broadcast_action] Estado da sala ${roomName} atualizado (LOAD_PROJECT).`
);
}
// Aplica ações autoritativas do editor de áudio
const maybeApplied = applyAuthoritativeAction(roomName, action);
// Debug: quem está na sala neste momento?
const socketsInRoom = await io.in(roomName).fetchSockets();
const idsInRoom = socketsInRoom.map((s) => s.id);
console.log(
`[broadcast_action] ${action.type} (token=${action.__token}) para sala "${roomName}" com ${idsInRoom.length} cliente(s):`,
idsInRoom
);
// Se foi uma ação do editor de áudio e foi aplicada, rebroadcast com __seq
if (maybeApplied) {
io.to(roomName).emit("action_broadcast", { action: maybeApplied });
return;
}
// Caso contrário, apenas repercute a ação como antes (sem __seq)
io.to(roomName).emit("action_broadcast", { action });
});
// =======================================
// Retrocompat (daw_action)
// =======================================
socket.on("daw_action", async (data) => {
const { room, action } = data || {};
if (!room || !action) return;
console.warn(
"[DEPRECATION] Recebido daw_action. Migre para broadcast_action com ACK."
);
// (Opcional, mas recomendado) Adicionando log dinâmico aqui também
try {
const roomLogger = getActionLoggerForRoom(room);
roomLogger.info(
{
timestamp: Date.now(),
socketId: socket.id,
action: action,
},
"action_received (deprecated daw_action)"
);
} catch (e) {
console.warn(`[Logger] Falha ao logar daw_action para sala ${room}:`, e);
}
if (!socket.rooms.has(room)) {
console.warn(
`[daw_action] ${socket.id} NÃO estava na sala ${room}. Forçando join...`
);
await socket.join(room);
logMyRooms(socket, "FORCED_JOIN(daw)");
}
const r = ensureRoom(room);
if (action.type === "LOAD_PROJECT" && action.xml) {
r.projectXml = action.xml;
saveRoom(room);
console.log(
`[daw_action] Estado da sala ${room} atualizado (LOAD_PROJECT).`
);
}
const socketsInRoom = await io.in(room).fetchSockets();
const idsInRoom = socketsInRoom.map((s) => s.id);
console.log(
`[daw_action] ${action.type} para sala "${room}" (${idsInRoom.length} cliente(s)):`,
idsInRoom
);
io.in(room).emit("action_broadcast", { action });
});
// =======================================
// Re-sync explícito (quando cliente detectar buraco de seq)
// =======================================
socket.on("audio_resync", ({ roomName, lastSeq }) => {
const room = ensureRoom(roomName);
// caminho simples: envia snapshot atual (estado autoritativo)
socket.emit("action_broadcast", {
action: {
type: "AUDIO_SNAPSHOT",
snapshot: room.audio,
__seq: room.seq,
__target: socket.id,
},
});
});
socket.on("disconnect", () => {
console.log(`Usuário desconectado: ${socket.id}`);
});
});
app.get("/", (req, res) => {
res.send(
"Servidor Backend V6 (Logs Dinâmicos) da DAW colaborativa está no ar!"
);
});
httpServer.listen(PORT, () => {
console.log(`Servidor escutando na porta http://localhost:${PORT}`);
});
+805
View File
@@ -0,0 +1,805 @@
// js/socket.js — V5.5 (Toast para Sync Mode)
// -------------------relómm------------------------------------------------------
// IMPORTS & STATE
// -----------------------------------------------------------------------------
import { appState, resetProjectState } from "./state.js";
import {
addTrackToState,
removeLastTrackFromState,
updateTrackSample,
} from "./pattern/pattern_state.js";
import {
addAudioTrackLane,
removeAudioClip,
addAudioClipToTimeline,
updateAudioClipProperties,
sliceAudioClip,
getAudioSnapshot, // 👈 novo
applyAudioSnapshot, // 👈 novo
} from "./audio/audio_state.js";
import {
togglePlayback,
stopPlayback,
rewindPlayback,
} from "./pattern/pattern_audio.js";
import {
startAudioEditorPlayback,
stopAudioEditorPlayback,
updateTransportLoop,
seekAudioEditor, // 👈 Adicionado
restartAudioEditorIfPlaying, // 👈 Adicionado
} from "./audio/audio_audio.js";
import { parseMmpContent } from "./file.js";
import { renderAll, showToast } from "./ui.js"; // showToast()
import { updateStepUI, renderPatternEditor } from "./pattern/pattern_ui.js";
// -----------------------------------------------------------------------------
// Gera um ID único otimista (ex: "track_1678886401000_abc123")
// -----------------------------------------------------------------------------
function generateUniqueId(prefix = "item") {
return `${prefix}_${Date.now()}_${Math.random()
.toString(36)
.substring(2, 9)}`;
}
// -----------------------------------------------------------------------------
// CONFIGURAÇÃO DO SOCKET.IO
// -----------------------------------------------------------------------------
const socket = io("http://localhost:33007", {
transports: ["websocket", "polling"],
withCredentials: true,
});
let USER_NAME = `Alicer-${Math.floor(Math.random() * 9999)}`;
let currentRoom = null;
// -----------------------------------------------------------------------------
// CLOCK SYNC — Sincroniza relógio cliente-servidor
// -----------------------------------------------------------------------------
let serverOffsetMs = 0;
let rttMs = 0;
function sampleServerTime() {
return new Promise((resolve) => {
const t0 = Date.now();
socket.emit("what_time_is_it", null, (reply) => {
const t1 = Date.now();
const rtt = t1 - t0;
const oneWay = rtt / 2;
const serverNow =
reply && typeof reply === "object" ? reply.serverNowMs : reply;
const offset = serverNow - (t0 + oneWay);
resolve({ offset, rtt });
});
});
}
function ewma(prev, next, alpha = 0.3) {
if (prev === null || prev === undefined) return next;
return prev * (1 - alpha) + next * alpha;
}
async function syncServerTime(iterations = 5) {
let off = null;
let rtt = null;
for (let i = 0; i < iterations; i++) {
try {
const s = await sampleServerTime();
off = ewma(off, s.offset);
rtt = ewma(rtt, s.rtt);
} catch {}
}
if (off != null) serverOffsetMs = off;
if (rtt != null) rttMs = rtt;
}
function delayFromServerTimeMs(scheduleAtServerMs) {
const localNow = Date.now();
const serverNowEst = localNow + serverOffsetMs;
return Math.max(0, scheduleAtServerMs - serverNowEst);
}
// -----------------------------------------------------------------------------
// ESTADO DE TOKENS / ACK / FALLBACK
// -----------------------------------------------------------------------------
let lastActionTimeout = null;
let lastBroadcastTimeout = null;
let pendingToken = null;
let lastActionToken = 0;
const processedTokens = new Set();
// -----------------------------------------------------------------------------
// FUNÇÕES AUXILIARES
// -----------------------------------------------------------------------------
export function setUserName(name) {
USER_NAME = name;
}
// -----------------------------------------------------------------------------
// CONEXÃO / JOIN / LOGS
// -----------------------------------------------------------------------------
socket.on("connect", () => {
console.log(`Conectado ao servidor com ID: ${socket.id}`);
showToast("✅ Conectado ao servidor", "success");
if (USER_NAME.startsWith("Alicer-")) {
USER_NAME = `Alicer-${socket.id.substring(0, 4)}`;
}
const urlRoom = new URLSearchParams(window.location.search).get("room");
currentRoom = urlRoom || null;
if (currentRoom) {
console.log(`Modo Online. Sala detectada: ${currentRoom}`);
showToast(`🎧 Conectado à sala ${currentRoom}`, "info");
// Mostra o botão se estiver online
const syncModeBtn = document.getElementById("sync-mode-btn");
//if (syncModeBtn) syncModeBtn.style.display = ""; // Garante visibilidade
} else {
console.log("Modo Local. Conectado, mas não em uma sala.");
showToast("🔌 Modo local (fora de sala)", "warning");
// Esconde se offline
const syncModeBtn = document.getElementById("sync-mode-btn");
//if (syncModeBtn) syncModeBtn.style.display = "none";
}
syncServerTime();
setInterval(syncServerTime, 10000);
});
// -----------------------------------------------------------------------------
// DETECÇÃO DE AD BLOCK
// -----------------------------------------------------------------------------
socket.on("connect_error", (err) => {
console.error("Falha crítica na conexão do Socket:", err.message);
alert(
"🚧 FALHA NA CONEXÃO 🚧\n\n❌Não foi possível conectar ao servidor em tempo real. ❌\n\n😅 Causa provável: Um Ad Blocker ou Firewall está bloqueando a conexão.\n\n😎 Por favor, desative seu Ad Blocker para este site e recarregue a página."
);
showToast(
"❌ Falha grave de conexão. Desativa o Ad Blocker? 😅",
"error",
10000
);
});
// -----------------------------------------------------------------------------
// RECEBER ESTADO SALVO DA SALA
// -----------------------------------------------------------------------------
socket.on("load_project_state", async (projectXml) => {
console.log("Recebendo estado salvo da sala...");
showToast("🔄 Recebendo estado atual da sala...", "info", 4000);
if (isLoadingProject) return;
isLoadingProject = true;
try {
await parseMmpContent(projectXml);
renderAll();
showToast("🎵 Projeto carregado com sucesso", "success");
} catch (e) {
console.error("Erro ao carregar projeto:", e);
showToast("❌ Erro ao carregar projeto", "error");
}
isLoadingProject = false;
});
// -----------------------------------------------------------------------------
// ENTRAR NA SALA (Join)
// -----------------------------------------------------------------------------
export function joinRoom() {
const urlRoom = new URLSearchParams(window.location.search).get("room");
currentRoom = urlRoom || currentRoom;
if (currentRoom) {
console.log(`Entrando na sala: ${currentRoom} como ${USER_NAME}`);
showToast(`🚪 Entrando na sala ${currentRoom}`, "info");
socket.emit("join_room", { roomName: currentRoom, userName: USER_NAME });
setTimeout(() => {
try {
const hasAudio =
(appState.audio?.clips?.length || 0) > 0 ||
(appState.audio?.tracks?.length || 0) > 0;
if (!hasAudio && currentRoom) {
sendAction({ type: "AUDIO_SNAPSHOT_REQUEST" });
}
} catch {}
}, 800);
} else {
console.warn("joinRoom() chamado, mas nenhuma sala encontrada na URL.");
showToast("⚠️ Nenhuma sala encontrada na URL", "warning");
}
}
// -----------------------------------------------------------------------------
// ENVIAR AÇÃO COM ACK + AGENDAMENTO DE TRANSPORTE
// -----------------------------------------------------------------------------
export function sendAction(action) {
const inRoom = Boolean(currentRoom);
// (Blindagem de ID/Validação)
if (action.type === "ADD_AUDIO_LANE" && !action.trackId) {
action.trackId = generateUniqueId("track");
console.log(`[SOCKET] ADD_AUDIO_LANE ID gerado: ${action.trackId}`);
}
if (action.type === "ADD_AUDIO_CLIP") {
if (!action.clipId) {
action.clipId = generateUniqueId("clip");
}
if (
!action.trackId ||
action.startTimeInSeconds == null ||
isNaN(action.startTimeInSeconds)
) {
console.error("[SOCKET] ADD_AUDIO_CLIP bloqueada:", action);
showToast("❌ Erro clip (inválido)", "error");
return;
}
}
if (action.type === "UPDATE_AUDIO_CLIP") {
if (!action.clipId || !action.props) {
console.error("[SOCKET] UPDATE_AUDIO_CLIP bloqueada (base):", action);
return;
}
const { trackId, startTimeInSeconds } = action.props;
if (
trackId !== undefined &&
(trackId == null || (typeof trackId === "number" && isNaN(trackId)))
) {
console.error("[SOCKET] UPDATE_AUDIO_CLIP bloqueada (trackId):", action);
return;
}
if (
startTimeInSeconds !== undefined &&
(startTimeInSeconds == null || isNaN(startTimeInSeconds))
) {
console.error(
"[SOCKET] UPDATE_AUDIO_CLIP bloqueada (startTime):",
action
);
return;
}
}
const token = (++lastActionToken).toString();
action.__token = token;
action.__senderId = socket.id;
action.__senderName = USER_NAME;
// =================================================================
// 👇 INÍCIO DA CORREÇÃO (Expandir `isTransport` para SET_SYNC_MODE)
// =================================================================
const isTransport =
action.type === "TOGGLE_PLAYBACK" ||
action.type === "STOP_PLAYBACK" ||
action.type === "REWIND_PLAYBACK" ||
action.type === "START_AUDIO_PLAYBACK" ||
action.type === "STOP_AUDIO_PLAYBACK" ||
action.type === "SET_LOOP_STATE" ||
action.type === "SET_SEEK_TIME" ||
action.type === "SET_SYNC_MODE"; // 👈 Adicionado
// =================================================================
// 👆 FIM DA CORREÇÃO
// =================================================================
if (inRoom && isTransport) {
const leadTimeMs = 200;
const serverNowEst = Date.now() + serverOffsetMs;
if (
action.type === "TOGGLE_PLAYBACK" ||
action.type === "START_AUDIO_PLAYBACK"
) {
action.scheduleAtServerMs = Math.round(serverNowEst + leadTimeMs);
}
}
// (Lógica Global/Local)
if (!inRoom || (isTransport && appState.global.syncMode === "local")) {
console.log("[SOCKET] (local) executando ação:", action.type);
handleActionBroadcast(action);
return;
}
if (isTransport && appState.global.syncMode === "global") {
action.__syncMode = "global";
}
console.log(
"[SOCKET] Enviando broadcast_action:",
action.type,
"para",
currentRoom,
"token=",
token
);
socket
.compress(false)
.emit("broadcast_action", { roomName: currentRoom, action }, (ack) => {
if (ack && ack.ok && ack.token === token) {
if (lastActionTimeout) {
clearTimeout(lastActionTimeout);
lastActionTimeout = null;
}
}
});
if (lastActionTimeout) clearTimeout(lastActionTimeout);
lastActionTimeout = setTimeout(() => {
console.warn("[SOCKET] ACK não recebido:", action.type, token);
showToast(`⚠️ ACK (${action.type})`, "warning");
}, 500);
if (lastBroadcastTimeout) clearTimeout(lastBroadcastTimeout);
pendingToken = token;
lastBroadcastTimeout = setTimeout(() => {
if (processedTokens.has(token)) return;
console.warn("[SOCKET] Eco não recebido, fallback:", action.type, token);
showToast(`⚠️ Eco (${action.type}), fallback`, "warning");
processedTokens.add(token);
handleActionBroadcast(action);
}, 900);
}
// HELPERS POP-UPS
function basenameNoExt(path) {
if (!path) return "";
const base = String(path).split(/[\\/]/).pop();
return base.replace(/\.[^/.]+$/, "");
}
function trackLabel(track, idx) {
const name =
track?.name ||
track?.label ||
track?.sampleName ||
track?.sample?.displayName;
return name ? `(Faixa ${idx + 1})` : `Faixa ${idx + 1}`;
}
function actorOf(action) {
const n = action.__senderName || action.userName;
if (n && typeof n === "string") return n;
const sid = action.__senderId ? String(action.__senderId) : "";
return `Alicer-${sid.slice(0, 4) || "????"}`;
}
function instrumentLabel(track, tIdx) {
const name =
track?.name ||
track?.label ||
track?.sampleName ||
track?.sample?.displayName ||
track?.filePath ||
track?.sampleFile ||
track?.samplePath;
if (name && typeof name === "string") {
const base = name.split(/[\\/]/).pop();
return base.replace(/\.[^/.]+$/, "");
}
return `Faixa ${tIdx + 1}`;
}
let rerenderScheduled = false;
function schedulePatternRerender() {
if (rerenderScheduled) return;
rerenderScheduled = true;
requestAnimationFrame(() => {
renderPatternEditor();
rerenderScheduled = false;
});
}
// RECEBER BROADCAST
socket.on("feedback", (msg) => {
console.log("[Servidor]", msg);
showToast(msg, "info");
});
socket.on("action_broadcast", (payload) => {
const action = payload && payload.type ? payload : payload?.action || payload;
if (action && action.__token) {
processedTokens.add(action.__token);
if (lastBroadcastTimeout && pendingToken === action.__token) {
clearTimeout(lastBroadcastTimeout);
lastBroadcastTimeout = null;
pendingToken = null;
}
if (lastActionTimeout) {
clearTimeout(lastActionTimeout);
lastActionTimeout = null;
}
}
handleActionBroadcast(action);
});
// PROCESSAR AÇÕES RECEBIDAS
let isLoadingProject = false;
async function handleActionBroadcast(action) {
if (!action || !action.type) return;
if (
action.type !== "LOAD_PROJECT" &&
action.type !== "RESET_PROJECT" &&
isLoadingProject
) {
console.warn(`[AÇÃO IGNORADA] ${action.type} (carregando).`);
return;
}
// (Filtro Global/Local)
const isTransport =
action.type === "TOGGLE_PLAYBACK" ||
action.type === "STOP_PLAYBACK" ||
action.type === "REWIND_PLAYBACK" ||
action.type === "START_AUDIO_PLAYBACK" ||
action.type === "STOP_AUDIO_PLAYBACK" ||
action.type === "SET_LOOP_STATE" ||
action.type === "SET_SEEK_TIME" ||
action.type === "SET_SYNC_MODE"; // 👈 Adicionado
const isFromSelf = action.__senderId === socket.id;
if (isTransport && !isFromSelf) {
if (appState.global.syncMode === "local") {
console.log(`[SOCKET] (ignorado) ${action.type}, modo local.`);
return;
}
if (action.__syncMode !== "global") {
console.log(`[SOCKET] (ignorado) ${action.type}, remetente não global.`);
return;
}
}
const scheduleAtServerMs = action.scheduleAtServerMs ?? null;
const delayMs = scheduleAtServerMs
? delayFromServerTimeMs(scheduleAtServerMs)
: 0;
switch (action.type) {
// Transporte Principal
case "TOGGLE_PLAYBACK": {
setTimeout(togglePlayback, delayMs);
const who = actorOf(action);
showToast(`▶ ${who} Play bases`, "info");
break;
}
case "STOP_PLAYBACK": {
setTimeout(stopPlayback, delayMs);
const who = actorOf(action);
showToast(`⏹ ${who} Stop bases`, "info");
break;
}
case "REWIND_PLAYBACK":
setTimeout(rewindPlayback, delayMs);
break;
// Transporte Áudio Editor
case "START_AUDIO_PLAYBACK":
if (action.loopState) {
appState.global.isLoopActive = action.loopState.isLoopActive;
appState.global.loopStartTime = action.loopState.loopStartTime;
appState.global.loopEndTime = action.loopState.loopEndTime;
const btn = document.getElementById("audio-editor-loop-btn");
if (btn) {
btn.classList.toggle("active", appState.global.isLoopActive);
}
updateTransportLoop();
try {
const area = document.getElementById("loop-region");
if (area)
area.classList.toggle("visible", appState.global.isLoopActive);
} catch (e) {}
}
const seekTime = action.seekTime ?? appState.audio.audioEditorSeekTime;
setTimeout(() => startAudioEditorPlayback(seekTime), delayMs);
break;
case "STOP_AUDIO_PLAYBACK":
setTimeout(
() => stopAudioEditorPlayback(action.rewind || false),
delayMs
);
break;
// =================================================================
// 👇 INÍCIO DA CORREÇÃO (Handlers Sincronia de Loop/Seek/SyncMode)
// =================================================================
case "SET_LOOP_STATE": {
const changed =
appState.global.isLoopActive !== !!action.isLoopActive ||
appState.global.loopStartTime !== action.loopStartTime ||
appState.global.loopEndTime !== action.loopEndTime;
if (changed) {
appState.global.isLoopActive = !!action.isLoopActive;
appState.global.loopStartTime = action.loopStartTime;
appState.global.loopEndTime = action.loopEndTime;
const btn = document.getElementById("audio-editor-loop-btn");
if (btn) {
btn.classList.toggle("active", appState.global.isLoopActive);
}
updateTransportLoop();
restartAudioEditorIfPlaying();
renderAll();
if (!isFromSelf) {
const who = actorOf(action);
showToast(`🔁 ${who} alterou o loop.`, "info");
}
}
break;
}
case "SET_SEEK_TIME": {
// Evita aplicar seek se o tempo for muito próximo para não causar "tremidas"
if (
Math.abs((appState.audio.audioEditorSeekTime || 0) - action.seekTime) >
0.05
) {
seekAudioEditor(action.seekTime);
if (!isFromSelf) {
const who = actorOf(action);
showToast(`⏯️ ${who} moveu a agulha.`, "info");
}
}
break;
}
case "SET_SYNC_MODE": {
const newMode = action.mode === "local" ? "local" : "global";
const changed = appState.global.syncMode !== newMode;
if (changed) {
appState.global.syncMode = newMode;
const btn = document.getElementById("sync-mode-btn");
if (btn) {
btn.classList.toggle("active", newMode === "global");
btn.textContent = newMode === "global" ? "Global" : "Local";
}
// Mostra o Toast AQUI, após a mudança ser aplicada
const who = actorOf(action);
const modeText = newMode === "global" ? "Global 🌐" : "Local 🏠";
showToast(`${who} mudou modo para ${modeText}`, "info");
}
break;
}
// =================================================================
// 👆 FIM DA CORREÇÃO
// =================================================================
// Estado Global
case "LOAD_PROJECT":
isLoadingProject = true;
showToast("📂 Carregando...", "info");
try {
await parseMmpContent(action.xml);
renderAll();
showToast("🎶 Projeto sync", "success");
} catch (e) {
console.error("Erro LOAD_PROJECT:", e);
showToast("❌ Erro projeto", "error");
}
isLoadingProject = false;
break;
case "RESET_PROJECT":
resetProjectState();
document.getElementById("bpm-input").value = 140;
document.getElementById("bars-input").value = 1;
document.getElementById("compasso-a-input").value = 4;
document.getElementById("compasso-b-input").value = 4;
renderAll();
const who = actorOf(action);
showToast(`🧹 Reset por ${who}`, "warning");
break;
// Configs
case "SET_BPM": {
document.getElementById("bpm-input").value = action.value;
renderAll();
const who = actorOf(action);
showToast(`🕰 ${who} BPM ${action.value}`, "info");
break;
}
case "SET_BARS": {
document.getElementById("bars-input").value = action.value;
renderAll();
const who = actorOf(action);
showToast(`🕰 ${who} Compasso add`, "info");
break;
}
case "SET_TIMESIG_A":
document.getElementById("compasso-a-input").value = action.value;
renderAll();
showToast("Compasso alt", "info");
break;
case "SET_TIMESIG_B":
document.getElementById("compasso-b-input").value = action.value;
renderAll();
showToast("Compasso alt", "info");
break;
// Tracks
case "ADD_TRACK": {
addTrackToState();
renderPatternEditor();
const who = actorOf(action);
showToast(`🥁 Faixa add por ${who}`, "info");
break;
}
case "REMOVE_LAST_TRACK": {
removeLastTrackFromState();
renderPatternEditor();
const who = actorOf(action);
showToast(`❌ Faixa remov. por ${who}`, "warning");
break;
}
case "ADD_AUDIO_LANE": {
const id = action.trackId;
if (!id) {
console.warn("ADD_AUDIO_LANE sem ID.");
break;
}
const exists = appState.audio.tracks.some((t) => t.id === id);
if (exists) {
console.log(`ADD_AUDIO_LANE ${id} já existe.`);
break;
}
appState.audio.tracks.push({
id: id,
name: `Pista ${appState.audio.tracks.length + 1}`,
});
renderAll();
const who = actorOf(action);
showToast(`🎧 Pista add por ${who}`, "info");
break;
}
case "REMOVE_AUDIO_CLIP":
if (removeAudioClip(action.clipId)) {
appState.global.selectedClipId = null;
renderAll();
const who = actorOf(action);
showToast(`🎚️ Clip remov. por ${who}`, "info");
}
break;
// Notes
case "TOGGLE_NOTE": {
const {
trackIndex: ti,
patternIndex: pi,
stepIndex: si,
isActive,
} = action;
const t = appState.pattern.tracks[ti];
if (t) {
t.patterns[pi] = t.patterns[pi] || { steps: [] };
t.patterns[pi].steps[si] = isActive;
try {
updateStepUI(ti, pi, si, isActive);
} catch {}
if (!isFromSelf) {
schedulePatternRerender();
}
}
const who = actorOf(action);
const v = isActive ? "+" : "-";
showToast(`🎯 ${who} ${v} nota ${ti + 1}.${pi + 1}.${si + 1}`, "info");
break;
}
// Samples
case "SET_TRACK_SAMPLE": {
const ti = action.trackIndex;
const t = appState.pattern.tracks[ti];
if (t) {
try {
await updateTrackSample(ti, action.filePath);
renderPatternEditor();
const who = actorOf(action);
const sn = basenameNoExt(action.filePath) || "?";
const tl = trackLabel(t, ti);
showToast(`🔊 ${who} trocou ${sn} em ${tl}`, "success");
} catch (err) {
console.error("Erro SET_TRACK_SAMPLE:", err);
showToast("❌ Erro sample", "error");
}
}
break;
}
// Snapshots
case "AUDIO_SNAPSHOT_REQUEST": {
const clips = appState.audio?.clips?.length || 0,
tracks = appState.audio?.tracks?.length || 0;
const iHave = clips > 0 || tracks > 0;
if (!iHave || isFromSelf) break;
if (!window.__lastSnapshotSentAt) window.__lastSnapshotSentAt = 0;
const now = Date.now();
if (now - window.__lastSnapshotSentAt < 1500) break;
window.__lastSnapshotSentAt = now;
try {
const snap = getAudioSnapshot();
sendAction({
type: "AUDIO_SNAPSHOT",
snapshot: snap,
__target: action.__senderId,
});
} catch (e) {
console.warn("Erro AUDIO_SNAPSHOT_REQUEST:", e);
}
break;
}
case "AUDIO_SNAPSHOT": {
if (action.__target && action.__target !== socket.id) break;
const hasClips = (appState.audio?.clips?.length || 0) > 0;
if (hasClips) break;
try {
await applyAudioSnapshot(action.snapshot);
renderAll();
const who = actorOf(action);
showToast(`🔁 Sync áudio por ${who}`, "success");
} catch (e) {
console.error("Erro AUDIO_SNAPSHOT:", e);
showToast("❌ Erro sync áudio", "error");
}
break;
}
// Clip Sync
case "ADD_AUDIO_CLIP": {
try {
const { filePath, trackId, startTimeInSeconds, clipId, name } = action;
if (
appState.audio?.clips?.some((c) => String(c.id) === String(clipId))
) {
break;
}
const trackExists = appState.audio?.tracks?.some(
(t) => t.id === trackId
);
if (!trackExists) {
console.warn(
`ADD_AUDIO_CLIP Pista ${trackId} não existe, criando...`
);
appState.audio.tracks.push({
id: trackId,
name: `Pista ${appState.audio.tracks.length + 1}`,
});
}
addAudioClipToTimeline(
filePath,
trackId,
startTimeInSeconds,
clipId,
name
);
renderAll();
const who = actorOf(action);
const track = appState.audio?.tracks?.find((t) => t.id === trackId);
const pista = track?.name || `Pista ${trackId}`;
showToast(
`🎧 ${who} add “${(name || filePath || "")
.split(/[\\/]/)
.pop()}” em ${pista}`,
"success"
);
} catch (e) {
console.error("Erro ADD_AUDIO_CLIP:", e);
showToast("❌ Erro add clip", "error");
}
break;
}
case "UPDATE_AUDIO_CLIP": {
try {
if (action.props?.__operation === "slice") {
sliceAudioClip(action.clipId, action.props.sliceTimeInTimeline);
} else {
updateAudioClipProperties(action.clipId, action.props || {});
}
renderAll();
const who = actorOf(action);
showToast(`✂️ Clip ${action.clipId} att por ${who}`, "info");
} catch (e) {
console.error("Erro UPDATE_AUDIO_CLIP:", e);
showToast("❌ Erro att clip", "error");
}
break;
}
default:
console.warn("Ação desconhecida:", action.type);
}
}
// EXPORTS
export { socket };
+55 -50
View File
@@ -1,66 +1,71 @@
// js/state.js
import { patternState, initializePatternState } from './pattern/pattern_state.js';
import { initializePatternState } from './pattern/pattern_state.js';
import { audioState, initializeAudioState } from './audio/audio_state.js';
import { DEFAULT_VOLUME, DEFAULT_PAN } from "./config.js";
// Estado global da aplicação
const globalState = {
sliceToolActive: false,
isPlaying: false,
isAudioEditorPlaying: false,
playbackIntervalId: null,
currentStep: 0,
metronomeEnabled: false,
originalXmlDoc: null,
currentBeatBasslineName: 'Novo Projeto',
masterVolume: DEFAULT_VOLUME,
masterPan: DEFAULT_PAN,
zoomLevelIndex: 2,
// --- ADICIONADO PARA A ÁREA DE LOOP ---
isLoopActive: false,
loopStartTime: 0,
loopEndTime: 8,
// --- ADICIONADO PARA O MODO DE REDIMENSIONAMENTO ---
resizeMode: 'trim', // Pode ser 'trim' (Modo 2) ou 'stretch' (Modo 1)
selectedClipId: null,
// --- RECORDING ---
isRecording: false,
  sliceToolActive: false,
  isPlaying: false,
  isAudioEditorPlaying: false,
  playbackIntervalId: null,
  currentStep: 0,
  metronomeEnabled: false,
  originalXmlDoc: null,
  currentBeatBasslineName: 'Novo Projeto',
  masterVolume: DEFAULT_VOLUME,
  masterPan: DEFAULT_PAN,
  zoomLevelIndex: 2,
  isLoopActive: false,
  loopStartTime: 0,
  loopEndTime: 8,
  resizeMode: 'trim',
  selectedClipId: null,
  isRecording: false,
  clipboard: null,
  lastRulerClickTime: 0,
};
// --- ADICIONE ESTE BLOCO ---
// Define o ESTADO INICIAL para o pattern module
const patternState = {
    tracks: [],
    activeTrackId: null,
    activePatternIndex: 0,
};
// --- FIM DA ADIÇÃO ---
// Combina todos os estados em um único objeto namespaced
export let appState = {
global: globalState,
pattern: patternState,
audio: audioState,
  global: globalState,
  pattern: patternState, // <-- AGORA 'patternState' está definido
  audio: audioState,
};
// Função para resetar o projeto para o estado inicial
export function resetProjectState() {
initializePatternState();
initializeAudioState();
    initializePatternState(); // Esta função vai MODIFICAR appState.pattern
    initializeAudioState();
Object.assign(globalState, {
sliceToolActive: false,
isPlaying: false,
isAudioEditorPlaying: false,
playbackIntervalId: null,
currentStep: 0,
metronomeEnabled: false,
originalXmlDoc: null,
currentBeatBasslineName: 'Novo Projeto',
masterVolume: DEFAULT_VOLUME,
masterPan: DEFAULT_PAN,
zoomLevelIndex: 2,
isLoopActive: false,
loopStartTime: 0,
loopEndTime: 8,
resizeMode: 'trim', // Reseta para o modo 'trim'
selectedClipId: null,
// --- RECORDING ---
isRecording: false,
});
    Object.assign(globalState, {
        sliceToolActive: false,
        isPlaying: false,
        isAudioEditorPlaying: false,
        playbackIntervalId: null,
        currentStep: 0,
        metronomeEnabled: false,
        originalXmlDoc: null,
        currentBeatBasslineName: 'Novo Projeto',
        masterVolume: DEFAULT_VOLUME,
        masterPan: DEFAULT_PAN,
        zoomLevelIndex: 2,
        isLoopActive: false,
        loopStartTime: 0,
        loopEndTime: 8,
        resizeMode: 'trim',
        selectedClipId: null,
        isRecording: false,
        clipboard: null,
        lastRulerClickTime: 0,
    });
}
+64
View File
@@ -154,3 +154,67 @@ export function closeOpenProjectModal() {
const openProjectModal = document.getElementById("open-project-modal");
openProjectModal.classList.remove("visible");
}
// ui.js — sistema global de avisos / toasts
export function showToast(message, type = "info", duration = 3000) {
// cria o contêiner se não existir
let container = document.getElementById("toast-container");
if (!container) {
container = document.createElement("div");
container.id = "toast-container";
Object.assign(container.style, {
position: "fixed",
bottom: "20px",
right: "20px",
zIndex: 9999,
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: "8px",
});
document.body.appendChild(container);
}
// cria o toast
const toast = document.createElement("div");
toast.className = `toast toast-${type}`;
toast.textContent = message;
Object.assign(toast.style, {
padding: "10px 16px",
borderRadius: "6px",
color: "#fff",
fontFamily: "sans-serif",
fontSize: "14px",
boxShadow: "0 2px 8px rgba(0,0,0,0.25)",
opacity: "0",
transform: "translateY(10px)",
transition: "all 0.3s ease",
maxWidth: "260px",
textAlign: "left",
wordWrap: "break-word",
});
// cores por tipo
const colors = {
info: "#3498db",
success: "#2ecc71",
warning: "#f1c40f",
error: "#e74c3c",
};
toast.style.backgroundColor = colors[type] || colors.info;
container.appendChild(toast);
// animação de entrada
setTimeout(() => {
toast.style.opacity = "1";
toast.style.transform = "translateY(0)";
}, 50);
// remover após tempo
setTimeout(() => {
toast.style.opacity = "0";
toast.style.transform = "translateY(10px)";
setTimeout(() => toast.remove(), 300);
}, duration);
}
+42 -16
View File
@@ -220,6 +220,14 @@
</div>
<div class="control-group">
<button id="metronome-btn" title="Metrônomo On/Off">Metrônomo</button>
<button
id="create-room-btn"
class="transport-btn"
title="Criar ou entrar em uma sala compartilhada"
>
<i class="fa-solid fa-users"></i>
<span style="margin-left: 8px">Criar Sala</span>
</button>
</div>
<div class="spacer"></div>
<div class="control-group master-controls">
@@ -276,14 +284,32 @@
</div>
<div class="tool-icons">
<i class="fa-solid fa-table-cells"></i
><i class="fa-solid fa-bars-staggered"></i
><i class="fa-solid fa-wave-square enabled"></i
><i
><i class="fa-solid fa-bars-staggered"></i>
<i
class="fa-solid fa-wave-square"
id="bounce-pattern-btn"
title="Renderizar Pattern para Pista de Áudio"
></i>
<i
class="fa-solid fa-plus"
id="add-bar-btn"
title="Adicionar 1 Compasso"
></i>
</div>
<div id="timeline-context-menu">
<div id="copy-clip">Copiar</div>
<div id="cut-clip">Recortar</div>
<div id="paste-clip">Colar</div>
<div class="menu-divider"></div>
<div id="delete-clip" style="color: var(--accent-red)">
Excluir Clipe
</div>
</div>
<div id="ruler-context-menu">
<div id="ruler-set-loop-start">Definir Início do Loop</div>
<div id="ruler-set-loop-end">Definir Fim do Loop</div>
</div>
<div class="zoom-controls">
<i class="fa-solid fa-minus" id="remove-instrument-btn"></i
><i class="fa-solid fa-plus" id="add-instrument-btn"></i>
@@ -337,6 +363,13 @@
id="audio-editor-loop-btn"
title="Ativar/Desativar Loop"
></i>
<button
id="sync-mode-btn"
class="control-btn active"
title="Modo de Sincronia de Playback (Global/Local)"
>
Global
</button>
<i
class="fa-solid fa-plus"
id="add-audio-track-btn"
@@ -370,7 +403,10 @@
<div class="timeline-container">
<div class="spectrogram-view-grid" style="width: 4000px">
<div class="timeline-clip" style="left: 100px; width: 400px"></div>
<div
class="timeline-clip"
style="left: 100px; width: 400px"
></div>
<div class="playhead"></div>
</div>
</div>
@@ -443,20 +479,10 @@
</div>
</div>
<div id="timeline-context-menu">
<div id="set-loop-start">Definir Início do Loop</div>
<div id="set-loop-end">Definir Fim do Loop</div>
<div class="menu-divider"></div>
<div id="copy-clip">Copiar</div>
<div id="cut-clip">Recortar</div>
<div id="paste-clip">Colar</div>
<div class="menu-divider"></div>
<div id="delete-clip" style="color: var(--accent-red)">Excluir Clipe</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.7.77/Tone.js"></script>
<script src="assets/js/creations/main.js" type="module"></script>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script src="assets/js/creations/socket.js" type="module"></script>
</body>
</html>
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../pino/bin.js
+122
View File
@@ -0,0 +1,122 @@
{
"name": "mmpSearch",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/pino": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz",
"integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^3.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz",
"integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
]
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"engines": {
"node": ">=10"
}
},
"node_modules/sonic-boom": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/thread-stream": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
"integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
"dependencies": {
"real-require": "^0.2.0"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
open-pull-requests-limit: 10
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "monthly"
open-pull-requests-limit: 10
+48
View File
@@ -0,0 +1,48 @@
name: CI
on:
push:
branches:
- main
- 'v*'
paths-ignore:
- 'docs/**'
- '*.md'
pull_request:
paths-ignore:
- 'docs/**'
- '*.md'
# This allows a subsequently queued workflow run to interrupt previous runs
concurrency:
group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}"
cancel-in-progress: true
jobs:
test:
name: ${{ matrix.node-version }} ${{ matrix.os }}
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
os: [macOS-latest, windows-latest, ubuntu-latest]
node-version: [18, 20, 22, 24]
steps:
- name: Check out repo
uses: actions/checkout@v5.0.0
with:
persist-credentials: false
- name: Setup Node ${{ matrix.node-version }}
uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm i --ignore-scripts
- name: Run tests
run: npm run test
+43
View File
@@ -0,0 +1,43 @@
name: Publish release
on:
workflow_dispatch:
inputs:
version:
description: 'The version number to tag and release'
required: true
type: string
prerelease:
description: 'Release as pre-release'
required: false
type: boolean
default: false
jobs:
release-npm:
runs-on: ubuntu-latest
environment: main
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v4
- uses: actions/setup-node@v5
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- run: npm install npm -g
- run: npm install
- name: Change version number and sync
run: |
node scripts/sync-version.mjs ${{ inputs.version }}
- name: GIT commit and push all changed files
run: |
git config --global user.name "mcollina"
git config --global user.email "hello@matteocollina.com"
git commit -n -a -m "Bumped v${{ inputs.version }}"
git push origin HEAD:${{ github.ref }}
- run: npm publish --access public --tag ${{ inputs.prerelease == true && 'next' || 'latest' }}
- name: 'Create release notes'
run: |
npx @matteo.collina/release-notes -a ${{ secrets.GITHUB_TOKEN }} -t v${{ inputs.version }} -r redact -o pinojs ${{ github.event.inputs.prerelease == 'true' && '-p' || '' }} -c ${{ github.ref }}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 pinojs contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+350
View File
@@ -0,0 +1,350 @@
# @pinojs/redact
> Smart object redaction for JavaScript applications - safe AND fast!
Redact JS objects with the same API as [fast-redact](https://github.com/davidmarkclements/fast-redact), but uses innovative **selective cloning** instead of mutating the original. This provides immutability guarantees with **performance competitive** to fast-redact for real-world usage patterns.
## Install
```bash
npm install @pinojs/redact
```
## Usage
```js
const slowRedact = require('@pinojs/redact')
const redact = slowRedact({
paths: ['headers.cookie', 'headers.authorization', 'user.password']
})
const obj = {
headers: {
cookie: 'secret-session-token',
authorization: 'Bearer abc123',
'x-forwarded-for': '192.168.1.1'
},
user: {
name: 'john',
password: 'secret123'
}
}
console.log(redact(obj))
// Output: {"headers":{"cookie":"[REDACTED]","authorization":"[REDACTED]","x-forwarded-for":"192.168.1.1"},"user":{"name":"john","password":"[REDACTED]"}}
// Original object is completely unchanged:
console.log(obj.headers.cookie) // 'secret-session-token'
```
## API
### slowRedact(options) → Function
Creates a redaction function with the specified options.
#### Options
- **paths** `string[]` (required): An array of strings describing the nested location of a key in an object
- **censor** `any` (optional, default: `'[REDACTED]'`): The value to replace sensitive data with. Can be a static value or function.
- **serialize** `Function|boolean` (optional, default: `JSON.stringify`): Serialization function. Set to `false` to return the redacted object.
- **remove** `boolean` (optional, default: `false`): Remove redacted keys from serialized output
- **strict** `boolean` (optional, default: `true`): Throw on non-object values or pass through primitives
#### Path Syntax
Supports the same path syntax as fast-redact:
- **Dot notation**: `'user.name'`, `'headers.cookie'`
- **Bracket notation**: `'user["password"]'`, `'headers["X-Forwarded-For"]'`
- **Array indices**: `'users[0].password'`, `'items[1].secret'`
- **Wildcards**:
- Terminal: `'users.*.password'` (redacts password for all users)
- Intermediate: `'*.password'` (redacts password at any level)
- Array wildcard: `'items.*'` (redacts all array elements)
#### Examples
**Custom censor value:**
```js
const redact = slowRedact({
paths: ['password'],
censor: '***HIDDEN***'
})
```
**Dynamic censor function:**
```js
const redact = slowRedact({
paths: ['password'],
censor: (value, path) => `REDACTED:${path}`
})
```
**Return object instead of JSON string:**
```js
const redact = slowRedact({
paths: ['secret'],
serialize: false
})
const result = redact({ secret: 'hidden', public: 'data' })
console.log(result.secret) // '[REDACTED]'
console.log(result.public) // 'data'
// Restore original values
const restored = result.restore()
console.log(restored.secret) // 'hidden'
```
**Custom serialization:**
```js
const redact = slowRedact({
paths: ['password'],
serialize: obj => JSON.stringify(obj, null, 2)
})
```
**Remove keys instead of redacting:**
```js
const redact = slowRedact({
paths: ['password', 'user.secret'],
remove: true
})
const obj = { username: 'john', password: 'secret123', user: { name: 'Jane', secret: 'hidden' } }
console.log(redact(obj))
// Output: {"username":"john","user":{"name":"Jane"}}
// Note: 'password' and 'user.secret' are completely absent, not redacted
```
**Wildcard patterns:**
```js
// Redact all properties in secrets object
const redact1 = slowRedact({ paths: ['secrets.*'] })
// Redact password for any user
const redact2 = slowRedact({ paths: ['users.*.password'] })
// Redact all items in an array
const redact3 = slowRedact({ paths: ['items.*'] })
// Remove all secrets instead of redacting them
const redact4 = slowRedact({ paths: ['secrets.*'], remove: true })
```
## Key Differences from fast-redact
### Safety First
- **No mutation**: Original objects are never modified
- **Selective cloning**: Only clones paths that need redaction, shares references for everything else
- **Restore capability**: Can restore original values when `serialize: false`
### Feature Compatibility
- **Remove option**: Full compatibility with fast-redact's `remove: true` option to completely omit keys from output
- **All path patterns**: Supports same syntax including wildcards, bracket notation, and array indices
- **Censor functions**: Dynamic censoring with path information passed as arrays
- **Serialization**: Custom serializers and `serialize: false` mode
### Smart Performance Approach
- **Selective cloning**: Analyzes redaction paths and only clones necessary object branches
- **Reference sharing**: Non-redacted properties maintain original object references
- **Memory efficiency**: Dramatically reduced memory usage for large objects with minimal redaction
- **Setup-time optimization**: Path analysis happens once during setup, not per redaction
### When to Use @pinojs/redact
- When immutability is critical
- When you need to preserve original objects
- When objects are shared across multiple contexts
- In functional programming environments
- When debugging and you need to compare before/after
- **Large objects with selective redaction** (now performance-competitive!)
- When memory efficiency with reference sharing is important
### When to Use fast-redact
- When absolute maximum performance is critical
- In extremely high-throughput scenarios (>100,000 ops/sec)
- When you control the object lifecycle and mutation is acceptable
- Very small objects where setup overhead matters
## Performance Benchmarks
@pinojs/redact uses **selective cloning** that provides good performance while maintaining immutability guarantees:
### Performance Results
| Operation Type | @pinojs/redact | fast-redact | Performance Ratio |
|---------------|-------------|-------------|-------------------|
| **Small objects** | ~690ns | ~200ns | ~3.5x slower |
| **Large objects (minimal redaction)** | **~18μs** | ~17μs | **~same performance** |
| **Large objects (wildcards)** | **~48μs** | ~37μs | **~1.3x slower** |
| **No redaction (large objects)** | **~18μs** | ~17μs | **~same performance** |
### Performance Improvements
@pinojs/redact is performance-competitive with fast-redact for large objects.
1. **Selective cloning approach**: Only clones object paths that need redaction
2. **Reference sharing**: Non-redacted properties share original object references
3. **Setup-time optimization**: Path analysis happens once, not per redaction
4. **Memory efficiency**: Dramatically reduced memory usage for typical use cases
### Benchmark Details
**Small Objects (~180 bytes)**:
- @pinojs/redact: **690ns** per operation
- fast-redact: **200ns** per operation
- **Slight setup overhead for small objects**
**Large Objects (~18KB, minimal redaction)**:
- @pinojs/redact: **18μs** per operation
- fast-redact: **17μs** per operation
- Near-identical performance
**Large Objects (~18KB, wildcard patterns)**:
- @pinojs/redact: **48μs** per operation
- fast-redact: **37μs** per operation
- Competitive performance for complex patterns
**Memory Considerations**:
- @pinojs/redact: **Selective reference sharing** (much lower memory usage than before)
- fast-redact: Mutates in-place (lowest memory usage)
- Large objects with few redacted paths now share most references
### When Performance Matters
Choose **fast-redact** when:
- Absolute maximum performance is critical (>100,000 ops/sec)
- Working with very small objects frequently
- Mutation is acceptable and controlled
- Every microsecond counts
Choose **@pinojs/redact** when:
- Immutability is required (with competitive performance)
- Objects are shared across contexts
- Large objects with selective redaction
- Memory efficiency through reference sharing is important
- Safety and functionality are priorities
- Most production applications (performance gap is minimal)
Run benchmarks yourself:
```bash
npm run bench
```
## How Selective Cloning Works
@pinojs/redact uses an innovative **selective cloning** approach that provides immutability guarantees while dramatically improving performance:
### Traditional Approach (before optimization)
```js
// Old approach: Deep clone entire object, then redact
const fullClone = deepClone(originalObject) // Clone everything
redact(fullClone, paths) // Then redact specific paths
```
### Selective Cloning Approach (current)
```js
// New approach: Analyze paths, clone only what's needed
const pathStructure = buildPathStructure(paths) // One-time setup
const selectiveClone = cloneOnlyNeededPaths(obj, pathStructure) // Smart cloning
redact(selectiveClone, paths) // Redact pre-identified paths
```
### Key Innovations
1. **Path Analysis**: Pre-processes redaction paths into an efficient tree structure
2. **Selective Cloning**: Only creates new objects for branches that contain redaction targets
3. **Reference Sharing**: Non-redacted properties maintain exact same object references
4. **Setup Optimization**: Path parsing happens once during redactor creation, not per redaction
### Example: Reference Sharing in Action
```js
const largeConfig = {
database: { /* large config object */ },
api: { /* another large config */ },
secrets: { password: 'hidden', apiKey: 'secret' }
}
const redact = slowRedact({ paths: ['secrets.password'] })
const result = redact(largeConfig)
// Only secrets object is cloned, database and api share original references
console.log(result.database === largeConfig.database) // true - shared reference!
console.log(result.api === largeConfig.api) // true - shared reference!
console.log(result.secrets === largeConfig.secrets) // false - cloned for redaction
```
This approach provides **immutability where it matters** while **sharing references where it's safe**.
## Remove Option
The `remove: true` option provides full compatibility with fast-redact's key removal functionality:
```js
const redact = slowRedact({
paths: ['password', 'secrets.*', 'users.*.credentials'],
remove: true
})
const data = {
username: 'john',
password: 'secret123',
secrets: { apiKey: 'abc', token: 'xyz' },
users: [
{ name: 'Alice', credentials: { password: 'pass1' } },
{ name: 'Bob', credentials: { password: 'pass2' } }
]
}
console.log(redact(data))
// Output: {"username":"john","secrets":{},"users":[{"name":"Alice"},{"name":"Bob"}]}
```
### Remove vs Redact Behavior
| Option | Behavior | Output Example |
|--------|----------|----------------|
| Default (redact) | Replaces values with censor | `{"password":"[REDACTED]"}` |
| `remove: true` | Completely omits keys | `{}` |
### Compatibility Notes
- **Same output as fast-redact**: Identical JSON output when using `remove: true`
- **Wildcard support**: Works with all wildcard patterns (`*`, `users.*`, `items.*.secret`)
- **Array handling**: Array items are set to `undefined` (omitted in JSON output)
- **Nested paths**: Supports deep removal (`users.*.credentials.password`)
- **Serialize compatibility**: Only works with `JSON.stringify` serializer (like fast-redact)
## Testing
```bash
# Run unit tests
npm test
# Run integration tests comparing with fast-redact
npm run test:integration
# Run all tests (unit + integration)
npm run test:all
# Run benchmarks
npm run bench
```
### Test Coverage
- **16 unit tests**: Core functionality and edge cases
- **16 integration tests**: Output compatibility with fast-redact
- **All major features**: Paths, wildcards, serialization, custom censors
- **Performance benchmarks**: Direct comparison with fast-redact
## License
MIT
## Contributing
Pull requests welcome! Please ensure all tests pass and add tests for new features.
+184
View File
@@ -0,0 +1,184 @@
const { bench, group, run } = require('mitata')
const slowRedact = require('../index.js')
const fastRedact = require('fast-redact')
// Test objects
const smallObj = {
user: { name: 'john', password: 'secret123' },
headers: { cookie: 'session-token', authorization: 'Bearer abc123' }
}
const largeObj = {
users: [],
metadata: {
version: '1.0.0',
secret: 'app-secret-key',
database: {
host: 'localhost',
password: 'db-password'
}
}
}
// Populate users array with for loop instead of Array.from
for (let i = 0; i < 100; i++) {
largeObj.users.push({
id: i,
name: `user${i}`,
email: `user${i}@example.com`,
password: `secret${i}`,
profile: {
age: 20 + (i % 50),
preferences: {
theme: 'dark',
notifications: true,
apiKey: `key-${i}-secret`
}
}
})
}
// Redaction configurations
const basicSlowRedact = slowRedact({
paths: ['user.password', 'headers.cookie']
})
const basicFastRedact = fastRedact({
paths: ['user.password', 'headers.cookie']
})
const wildcardSlowRedact = slowRedact({
paths: ['users.*.password', 'users.*.profile.preferences.apiKey']
})
const wildcardFastRedact = fastRedact({
paths: ['users.*.password', 'users.*.profile.preferences.apiKey']
})
const deepSlowRedact = slowRedact({
paths: ['metadata.secret', 'metadata.database.password']
})
const deepFastRedact = fastRedact({
paths: ['metadata.secret', 'metadata.database.password']
})
group('Small Object Redaction - @pinojs/redact', () => {
bench('basic paths', () => {
basicSlowRedact(smallObj)
})
bench('serialize: false', () => {
const redact = slowRedact({
paths: ['user.password'],
serialize: false
})
redact(smallObj)
})
bench('custom censor function', () => {
const redact = slowRedact({
paths: ['user.password'],
censor: (value, path) => `HIDDEN:${path}`
})
redact(smallObj)
})
})
group('Small Object Redaction - fast-redact', () => {
bench('basic paths', () => {
basicFastRedact(smallObj)
})
bench('serialize: false', () => {
const redact = fastRedact({
paths: ['user.password'],
serialize: false
})
redact(smallObj)
})
bench('custom censor function', () => {
const redact = fastRedact({
paths: ['user.password'],
censor: (value, path) => `HIDDEN:${path}`
})
redact(smallObj)
})
})
group('Large Object Redaction - @pinojs/redact', () => {
bench('wildcard patterns', () => {
wildcardSlowRedact(largeObj)
})
bench('deep nested paths', () => {
deepSlowRedact(largeObj)
})
bench('multiple wildcards', () => {
const redact = slowRedact({
paths: ['users.*.password', 'users.*.profile.preferences.*']
})
redact(largeObj)
})
})
group('Large Object Redaction - fast-redact', () => {
bench('wildcard patterns', () => {
wildcardFastRedact(largeObj)
})
bench('deep nested paths', () => {
deepFastRedact(largeObj)
})
bench('multiple wildcards', () => {
const redact = fastRedact({
paths: ['users.*.password', 'users.*.profile.preferences.*']
})
redact(largeObj)
})
})
group('Direct Performance Comparison', () => {
bench('@pinojs/redact - basic paths', () => {
basicSlowRedact(smallObj)
})
bench('fast-redact - basic paths', () => {
basicFastRedact(smallObj)
})
bench('@pinojs/redact - wildcards', () => {
wildcardSlowRedact(largeObj)
})
bench('fast-redact - wildcards', () => {
wildcardFastRedact(largeObj)
})
})
group('Object Cloning Overhead', () => {
bench('@pinojs/redact - no redaction (clone only)', () => {
const redact = slowRedact({ paths: [] })
redact(smallObj)
})
bench('fast-redact - no redaction', () => {
const redact = fastRedact({ paths: [] })
redact(smallObj)
})
bench('@pinojs/redact - large object clone', () => {
const redact = slowRedact({ paths: [] })
redact(largeObj)
})
bench('fast-redact - large object', () => {
const redact = fastRedact({ paths: [] })
redact(largeObj)
})
})
run()
+1
View File
@@ -0,0 +1 @@
module.exports = require('neostandard')()
+52
View File
@@ -0,0 +1,52 @@
export = F;
/**
* When called without any options, or with a zero length paths array, @pinojs/redact will return JSON.stringify or the serialize option, if set.
* @param redactOptions
* @param redactOptions.paths An array of strings describing the nested location of a key in an object.
* @param redactOptions.censor This is the value which overwrites redacted properties.
* @param redactOptions.remove The remove option, when set to true will cause keys to be removed from the serialized output.
* @param redactOptions.serialize The serialize option may either be a function or a boolean. If a function is supplied, this will be used to serialize the redacted object.
* @param redactOptions.strict The strict option, when set to true, will cause the redactor function to throw if instead of an object it finds a primitive.
* @returns Redacted value from input
*/
declare function F(
redactOptions: F.RedactOptionsNoSerialize
): F.redactFnNoSerialize;
declare function F(redactOptions?: F.RedactOptions): F.redactFn;
declare namespace F {
/** Redacts input */
type redactFn = <T>(input: T) => string | T;
/** Redacts input without serialization */
type redactFnNoSerialize = redactFn & {
/** Method that allowing the redacted keys to be restored with the original data. Supplied only when serialize option set to false. */
restore<T>(input: T): T;
};
interface RedactOptions {
/** An array of strings describing the nested location of a key in an object. */
paths?: string[] | undefined;
/** This is the value which overwrites redacted properties. */
censor?: string | ((v: any) => any) | undefined;
/** The remove option, when set to true will cause keys to be removed from the serialized output. */
remove?: boolean | undefined;
/**
* The serialize option may either be a function or a boolean. If a function is supplied, this will be used to serialize the redacted object.
* The default serialize is the function JSON.stringify
*/
serialize?: boolean | ((v: any) => any) | undefined;
/** The strict option, when set to true, will cause the redactor function to throw if instead of an object it finds a primitive. */
strict?: boolean | undefined;
}
/** RedactOptions without serialization. Instead of the serialized object, the output of the redactor function will be the mutated object itself. */
interface RedactOptionsNoSerialize extends RedactOptions {
serialize: false;
}
}
+529
View File
@@ -0,0 +1,529 @@
'use strict'
function deepClone (obj) {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (obj instanceof Date) {
return new Date(obj.getTime())
}
if (obj instanceof Array) {
const cloned = []
for (let i = 0; i < obj.length; i++) {
cloned[i] = deepClone(obj[i])
}
return cloned
}
if (typeof obj === 'object') {
const cloned = Object.create(Object.getPrototypeOf(obj))
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
cloned[key] = deepClone(obj[key])
}
}
return cloned
}
return obj
}
function parsePath (path) {
const parts = []
let current = ''
let inBrackets = false
let inQuotes = false
let quoteChar = ''
for (let i = 0; i < path.length; i++) {
const char = path[i]
if (!inBrackets && char === '.') {
if (current) {
parts.push(current)
current = ''
}
} else if (char === '[') {
if (current) {
parts.push(current)
current = ''
}
inBrackets = true
} else if (char === ']' && inBrackets) {
// Always push the current value when closing brackets, even if it's an empty string
parts.push(current)
current = ''
inBrackets = false
inQuotes = false
} else if ((char === '"' || char === "'") && inBrackets) {
if (!inQuotes) {
inQuotes = true
quoteChar = char
} else if (char === quoteChar) {
inQuotes = false
quoteChar = ''
} else {
current += char
}
} else {
current += char
}
}
if (current) {
parts.push(current)
}
return parts
}
function setValue (obj, parts, value) {
let current = obj
for (let i = 0; i < parts.length - 1; i++) {
const key = parts[i]
// Type safety: Check if current is an object before using 'in' operator
if (typeof current !== 'object' || current === null || !(key in current)) {
return false // Path doesn't exist, don't create it
}
if (typeof current[key] !== 'object' || current[key] === null) {
return false // Path doesn't exist properly
}
current = current[key]
}
const lastKey = parts[parts.length - 1]
if (lastKey === '*') {
if (Array.isArray(current)) {
for (let i = 0; i < current.length; i++) {
current[i] = value
}
} else if (typeof current === 'object' && current !== null) {
for (const key in current) {
if (Object.prototype.hasOwnProperty.call(current, key)) {
current[key] = value
}
}
}
} else {
// Type safety: Check if current is an object before using 'in' operator
if (typeof current === 'object' && current !== null && lastKey in current && Object.prototype.hasOwnProperty.call(current, lastKey)) {
current[lastKey] = value
}
}
return true
}
function removeKey (obj, parts) {
let current = obj
for (let i = 0; i < parts.length - 1; i++) {
const key = parts[i]
// Type safety: Check if current is an object before using 'in' operator
if (typeof current !== 'object' || current === null || !(key in current)) {
return false // Path doesn't exist, don't create it
}
if (typeof current[key] !== 'object' || current[key] === null) {
return false // Path doesn't exist properly
}
current = current[key]
}
const lastKey = parts[parts.length - 1]
if (lastKey === '*') {
if (Array.isArray(current)) {
// For arrays, we can't really "remove" all items as that would change indices
// Instead, we set them to undefined which will be omitted by JSON.stringify
for (let i = 0; i < current.length; i++) {
current[i] = undefined
}
} else if (typeof current === 'object' && current !== null) {
for (const key in current) {
if (Object.prototype.hasOwnProperty.call(current, key)) {
delete current[key]
}
}
}
} else {
// Type safety: Check if current is an object before using 'in' operator
if (typeof current === 'object' && current !== null && lastKey in current && Object.prototype.hasOwnProperty.call(current, lastKey)) {
delete current[lastKey]
}
}
return true
}
// Sentinel object to distinguish between undefined value and non-existent path
const PATH_NOT_FOUND = Symbol('PATH_NOT_FOUND')
function getValueIfExists (obj, parts) {
let current = obj
for (const part of parts) {
if (current === null || current === undefined) {
return PATH_NOT_FOUND
}
// Type safety: Check if current is an object before property access
if (typeof current !== 'object' || current === null) {
return PATH_NOT_FOUND
}
// Check if the property exists before accessing it
if (!(part in current)) {
return PATH_NOT_FOUND
}
current = current[part]
}
return current
}
function getValue (obj, parts) {
let current = obj
for (const part of parts) {
if (current === null || current === undefined) {
return undefined
}
// Type safety: Check if current is an object before property access
if (typeof current !== 'object' || current === null) {
return undefined
}
current = current[part]
}
return current
}
function redactPaths (obj, paths, censor, remove = false) {
for (const path of paths) {
const parts = parsePath(path)
if (parts.includes('*')) {
redactWildcardPath(obj, parts, censor, path, remove)
} else {
if (remove) {
removeKey(obj, parts)
} else {
// Get value only if path exists - single traversal
const value = getValueIfExists(obj, parts)
if (value === PATH_NOT_FOUND) {
continue
}
const actualCensor = typeof censor === 'function'
? censor(value, parts)
: censor
setValue(obj, parts, actualCensor)
}
}
}
}
function redactWildcardPath (obj, parts, censor, originalPath, remove = false) {
const wildcardIndex = parts.indexOf('*')
if (wildcardIndex === parts.length - 1) {
const parentParts = parts.slice(0, -1)
let current = obj
for (const part of parentParts) {
if (current === null || current === undefined) return
// Type safety: Check if current is an object before property access
if (typeof current !== 'object' || current === null) return
current = current[part]
}
if (Array.isArray(current)) {
if (remove) {
// For arrays, set all items to undefined which will be omitted by JSON.stringify
for (let i = 0; i < current.length; i++) {
current[i] = undefined
}
} else {
for (let i = 0; i < current.length; i++) {
const indexPath = [...parentParts, i.toString()]
const actualCensor = typeof censor === 'function'
? censor(current[i], indexPath)
: censor
current[i] = actualCensor
}
}
} else if (typeof current === 'object' && current !== null) {
if (remove) {
// Collect keys to delete to avoid issues with deleting during iteration
const keysToDelete = []
for (const key in current) {
if (Object.prototype.hasOwnProperty.call(current, key)) {
keysToDelete.push(key)
}
}
for (const key of keysToDelete) {
delete current[key]
}
} else {
for (const key in current) {
const keyPath = [...parentParts, key]
const actualCensor = typeof censor === 'function'
? censor(current[key], keyPath)
: censor
current[key] = actualCensor
}
}
}
} else {
redactIntermediateWildcard(obj, parts, censor, wildcardIndex, originalPath, remove)
}
}
function redactIntermediateWildcard (obj, parts, censor, wildcardIndex, originalPath, remove = false) {
const beforeWildcard = parts.slice(0, wildcardIndex)
const afterWildcard = parts.slice(wildcardIndex + 1)
const pathArray = [] // Cached array to avoid allocations
function traverse (current, pathLength) {
if (pathLength === beforeWildcard.length) {
if (Array.isArray(current)) {
for (let i = 0; i < current.length; i++) {
pathArray[pathLength] = i.toString()
traverse(current[i], pathLength + 1)
}
} else if (typeof current === 'object' && current !== null) {
for (const key in current) {
pathArray[pathLength] = key
traverse(current[key], pathLength + 1)
}
}
} else if (pathLength < beforeWildcard.length) {
const nextKey = beforeWildcard[pathLength]
// Type safety: Check if current is an object before using 'in' operator
if (current && typeof current === 'object' && current !== null && nextKey in current) {
pathArray[pathLength] = nextKey
traverse(current[nextKey], pathLength + 1)
}
} else {
// Check if afterWildcard contains more wildcards
if (afterWildcard.includes('*')) {
// Recursively handle remaining wildcards
// Wrap censor to prepend current path context
const wrappedCensor = typeof censor === 'function'
? (value, path) => {
const fullPath = [...pathArray.slice(0, pathLength), ...path]
return censor(value, fullPath)
}
: censor
redactWildcardPath(current, afterWildcard, wrappedCensor, originalPath, remove)
} else {
// No more wildcards, apply the redaction directly
if (remove) {
removeKey(current, afterWildcard)
} else {
const actualCensor = typeof censor === 'function'
? censor(getValue(current, afterWildcard), [...pathArray.slice(0, pathLength), ...afterWildcard])
: censor
setValue(current, afterWildcard, actualCensor)
}
}
}
}
if (beforeWildcard.length === 0) {
traverse(obj, 0)
} else {
let current = obj
for (let i = 0; i < beforeWildcard.length; i++) {
const part = beforeWildcard[i]
if (current === null || current === undefined) return
// Type safety: Check if current is an object before property access
if (typeof current !== 'object' || current === null) return
current = current[part]
pathArray[i] = part
}
if (current !== null && current !== undefined) {
traverse(current, beforeWildcard.length)
}
}
}
function buildPathStructure (pathsToClone) {
if (pathsToClone.length === 0) {
return null // No paths to redact
}
// Parse all paths and organize by depth
const pathStructure = new Map()
for (const path of pathsToClone) {
const parts = parsePath(path)
let current = pathStructure
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (!current.has(part)) {
current.set(part, new Map())
}
current = current.get(part)
}
}
return pathStructure
}
function selectiveClone (obj, pathStructure) {
if (!pathStructure) {
return obj // No paths to redact, return original
}
function cloneSelectively (source, pathMap, depth = 0) {
if (!pathMap || pathMap.size === 0) {
return source // No more paths to clone, return reference
}
if (source === null || typeof source !== 'object') {
return source
}
if (source instanceof Date) {
return new Date(source.getTime())
}
if (Array.isArray(source)) {
const cloned = []
for (let i = 0; i < source.length; i++) {
const indexStr = i.toString()
if (pathMap.has(indexStr) || pathMap.has('*')) {
cloned[i] = cloneSelectively(source[i], pathMap.get(indexStr) || pathMap.get('*'))
} else {
cloned[i] = source[i] // Share reference for non-redacted items
}
}
return cloned
}
// Handle objects
const cloned = Object.create(Object.getPrototypeOf(source))
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
if (pathMap.has(key) || pathMap.has('*')) {
cloned[key] = cloneSelectively(source[key], pathMap.get(key) || pathMap.get('*'))
} else {
cloned[key] = source[key] // Share reference for non-redacted properties
}
}
}
return cloned
}
return cloneSelectively(obj, pathStructure)
}
function validatePath (path) {
if (typeof path !== 'string') {
throw new Error('Paths must be (non-empty) strings')
}
if (path === '') {
throw new Error('Invalid redaction path ()')
}
// Check for double dots
if (path.includes('..')) {
throw new Error(`Invalid redaction path (${path})`)
}
// Check for comma-separated paths (invalid syntax)
if (path.includes(',')) {
throw new Error(`Invalid redaction path (${path})`)
}
// Check for unmatched brackets
let bracketCount = 0
let inQuotes = false
let quoteChar = ''
for (let i = 0; i < path.length; i++) {
const char = path[i]
if ((char === '"' || char === "'") && bracketCount > 0) {
if (!inQuotes) {
inQuotes = true
quoteChar = char
} else if (char === quoteChar) {
inQuotes = false
quoteChar = ''
}
} else if (char === '[' && !inQuotes) {
bracketCount++
} else if (char === ']' && !inQuotes) {
bracketCount--
if (bracketCount < 0) {
throw new Error(`Invalid redaction path (${path})`)
}
}
}
if (bracketCount !== 0) {
throw new Error(`Invalid redaction path (${path})`)
}
}
function validatePaths (paths) {
if (!Array.isArray(paths)) {
throw new TypeError('paths must be an array')
}
for (const path of paths) {
validatePath(path)
}
}
function slowRedact (options = {}) {
const {
paths = [],
censor = '[REDACTED]',
serialize = JSON.stringify,
strict = true,
remove = false
} = options
// Validate paths upfront to match fast-redact behavior
validatePaths(paths)
// Build path structure once during setup, not on every call
const pathStructure = buildPathStructure(paths)
return function redact (obj) {
if (strict && (obj === null || typeof obj !== 'object')) {
if (obj === null || obj === undefined) {
return serialize ? serialize(obj) : obj
}
if (typeof obj !== 'object') {
return serialize ? serialize(obj) : obj
}
}
// Only clone paths that need redaction
const cloned = selectiveClone(obj, pathStructure)
const original = obj // Keep reference to original for restore
let actualCensor = censor
if (typeof censor === 'function') {
actualCensor = censor
}
redactPaths(cloned, paths, actualCensor, remove)
if (serialize === false) {
cloned.restore = function () {
return deepClone(original) // Full clone only when restore is called
}
return cloned
}
if (typeof serialize === 'function') {
return serialize(cloned)
}
return JSON.stringify(cloned)
}
}
module.exports = slowRedact
+22
View File
@@ -0,0 +1,22 @@
import { expectType, expectAssignable } from "tsd";
import slowRedact from ".";
import type { redactFn, redactFnNoSerialize } from ".";
// should return redactFn
expectType<redactFn>(slowRedact());
expectType<redactFn>(slowRedact({ paths: [] }));
expectType<redactFn>(slowRedact({ paths: ["some.path"] }));
expectType<redactFn>(slowRedact({ paths: [], censor: "[REDACTED]" }));
expectType<redactFn>(slowRedact({ paths: [], strict: true }));
expectType<redactFn>(slowRedact({ paths: [], serialize: JSON.stringify }));
expectType<redactFn>(slowRedact({ paths: [], serialize: true }));
expectType<redactFnNoSerialize>(slowRedact({ paths: [], serialize: false }));
expectType<redactFn>(slowRedact({ paths: [], remove: true }));
// should return string
expectType<string>(slowRedact()(""));
// should return string or T
expectAssignable<string | { someField: string }>(
slowRedact()({ someField: "someValue" })
);
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@pinojs/redact",
"version": "0.4.0",
"description": "Redact JS objects",
"main": "index.js",
"types": "index.d.ts",
"scripts": {
"test": "node --test && npm run test:types",
"test:integration": "node --test test/integration.test.js",
"test:types": "tsd",
"test:all": "node --test test/*.test.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"bench": "node benchmarks/basic.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/pinojs/redact.git"
},
"keywords": [
"redact"
],
"author": "Matteo Collina <hello@matteocollina.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/pinojs/redact/issues"
},
"homepage": "https://github.com/pinojs/redact#readme",
"devDependencies": {
"eslint": "^9.36.0",
"fast-redact": "^3.5.0",
"mitata": "^1.0.34",
"neostandard": "^0.12.2",
"tsd": "^0.33.0",
"typescript": "^5.9.2"
}
}
+20
View File
@@ -0,0 +1,20 @@
import fs from 'node:fs'
import path from 'node:path'
const packageJsonPath = path.resolve(import.meta.dirname, '../package.json')
let { version } = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'))
let passedVersion = process.argv[2]
if (passedVersion) {
passedVersion = passedVersion.trim().replace(/^v/, '')
if (version !== passedVersion) {
console.log(`Syncing version from ${version} to ${passedVersion}`)
version = passedVersion
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'))
packageJson.version = version
fs.writeFileSync(path.resolve('./package.json'), JSON.stringify(packageJson, null, 2) + '\n', { encoding: 'utf-8' })
}
} else {
throw new Error('Version argument is required')
}
+211
View File
@@ -0,0 +1,211 @@
'use strict'
// Node.js test comparing @pinojs/redact vs fast-redact for multiple wildcard patterns
// This test validates that @pinojs/redact correctly handles 3+ consecutive wildcards
// matching the behavior of fast-redact
const { test } = require('node:test')
const { strict: assert } = require('node:assert')
const fastRedact = require('fast-redact')
const slowRedact = require('../index.js')
// Helper function to test redaction and track which values were censored
function testRedactDirect (library, pattern, testData = {}) {
const matches = []
const redactor = library === '@pinojs/redact' ? slowRedact : fastRedact
try {
const redact = redactor({
paths: [pattern],
censor: (value, path) => {
if (
value !== undefined &&
value !== null &&
typeof value === 'string' &&
value.includes('secret')
) {
matches.push({
value,
path: path ? path.join('.') : 'unknown'
})
}
return '[REDACTED]'
}
})
redact(JSON.parse(JSON.stringify(testData)))
return {
library,
pattern,
matches,
success: true,
testData
}
} catch (error) {
return {
library,
pattern,
matches: [],
success: false,
error: error.message,
testData
}
}
}
function testSlowRedactDirect (pattern, testData) {
return testRedactDirect('@pinojs/redact', pattern, testData)
}
function testFastRedactDirect (pattern, testData) {
return testRedactDirect('fast-redact', pattern, testData)
}
test('@pinojs/redact: *.password (2 levels)', () => {
const result = testSlowRedactDirect('*.password', {
simple: { password: 'secret-2-levels' }
})
assert.strictEqual(result.success, true)
assert.strictEqual(result.matches.length, 1)
assert.strictEqual(result.matches[0].value, 'secret-2-levels')
})
test('@pinojs/redact: *.*.password (3 levels)', () => {
const result = testSlowRedactDirect('*.*.password', {
simple: { password: 'secret-2-levels' },
user: { auth: { password: 'secret-3-levels' } }
})
assert.strictEqual(result.success, true)
assert.strictEqual(result.matches.length, 1)
assert.strictEqual(result.matches[0].value, 'secret-3-levels')
})
test('@pinojs/redact: *.*.*.password (4 levels)', () => {
const result = testSlowRedactDirect('*.*.*.password', {
simple: { password: 'secret-2-levels' },
user: { auth: { password: 'secret-3-levels' } },
nested: { deep: { auth: { password: 'secret-4-levels' } } }
})
assert.strictEqual(result.success, true)
assert.strictEqual(result.matches.length, 1)
assert.strictEqual(result.matches[0].value, 'secret-4-levels')
})
test('@pinojs/redact: *.*.*.*.password (5 levels)', () => {
const result = testSlowRedactDirect('*.*.*.*.password', {
simple: { password: 'secret-2-levels' },
user: { auth: { password: 'secret-3-levels' } },
nested: { deep: { auth: { password: 'secret-4-levels' } } },
config: {
user: { auth: { settings: { password: 'secret-5-levels' } } }
}
})
assert.strictEqual(result.success, true)
assert.strictEqual(result.matches.length, 1)
assert.strictEqual(result.matches[0].value, 'secret-5-levels')
})
test('@pinojs/redact: *.*.*.*.*.password (6 levels)', () => {
const result = testSlowRedactDirect('*.*.*.*.*.password', {
simple: { password: 'secret-2-levels' },
user: { auth: { password: 'secret-3-levels' } },
nested: { deep: { auth: { password: 'secret-4-levels' } } },
config: {
user: { auth: { settings: { password: 'secret-5-levels' } } }
},
data: {
reqConfig: {
data: {
credentials: {
settings: {
password: 'real-secret-6-levels'
}
}
}
}
}
})
assert.strictEqual(result.success, true)
assert.strictEqual(result.matches.length, 1)
assert.strictEqual(result.matches[0].value, 'real-secret-6-levels')
})
test('fast-redact: *.password (2 levels)', () => {
const result = testFastRedactDirect('*.password', {
simple: { password: 'secret-2-levels' }
})
assert.strictEqual(result.success, true)
assert.strictEqual(result.matches.length, 1)
assert.strictEqual(result.matches[0].value, 'secret-2-levels')
})
test('fast-redact: *.*.password (3 levels)', () => {
const result = testFastRedactDirect('*.*.password', {
simple: { password: 'secret-2-levels' },
user: { auth: { password: 'secret-3-levels' } }
})
assert.strictEqual(result.success, true)
assert.strictEqual(result.matches.length, 1)
assert.strictEqual(result.matches[0].value, 'secret-3-levels')
})
test('fast-redact: *.*.*.password (4 levels)', () => {
const result = testFastRedactDirect('*.*.*.password', {
simple: { password: 'secret-2-levels' },
user: { auth: { password: 'secret-3-levels' } },
nested: { deep: { auth: { password: 'secret-4-levels' } } }
})
assert.strictEqual(result.success, true)
assert.strictEqual(result.matches.length, 1)
assert.strictEqual(result.matches[0].value, 'secret-4-levels')
})
test('fast-redact: *.*.*.*.password (5 levels)', () => {
const result = testFastRedactDirect('*.*.*.*.password', {
simple: { password: 'secret-2-levels' },
user: { auth: { password: 'secret-3-levels' } },
nested: { deep: { auth: { password: 'secret-4-levels' } } },
config: {
user: { auth: { settings: { password: 'secret-5-levels' } } }
}
})
assert.strictEqual(result.success, true)
assert.strictEqual(result.matches.length, 1)
assert.strictEqual(result.matches[0].value, 'secret-5-levels')
})
test('fast-redact: *.*.*.*.*.password (6 levels)', () => {
const result = testFastRedactDirect('*.*.*.*.*.password', {
simple: { password: 'secret-2-levels' },
user: { auth: { password: 'secret-3-levels' } },
nested: { deep: { auth: { password: 'secret-4-levels' } } },
config: {
user: { auth: { settings: { password: 'secret-5-levels' } } }
},
data: {
reqConfig: {
data: {
credentials: {
settings: {
password: 'real-secret-6-levels'
}
}
}
}
}
})
assert.strictEqual(result.success, true)
assert.strictEqual(result.matches.length, 1)
assert.strictEqual(result.matches[0].value, 'real-secret-6-levels')
})
+824
View File
@@ -0,0 +1,824 @@
const { test } = require('node:test')
const { strict: assert } = require('node:assert')
const slowRedact = require('../index.js')
test('basic path redaction', () => {
const obj = {
headers: {
cookie: 'secret-cookie',
authorization: 'Bearer token'
},
body: { message: 'hello' }
}
const redact = slowRedact({ paths: ['headers.cookie'] })
const result = redact(obj)
// Original object should remain unchanged
assert.strictEqual(obj.headers.cookie, 'secret-cookie')
// Result should have redacted path
const parsed = JSON.parse(result)
assert.strictEqual(parsed.headers.cookie, '[REDACTED]')
assert.strictEqual(parsed.headers.authorization, 'Bearer token')
assert.strictEqual(parsed.body.message, 'hello')
})
test('multiple paths redaction', () => {
const obj = {
user: { name: 'john', password: 'secret' },
session: { token: 'abc123' }
}
const redact = slowRedact({
paths: ['user.password', 'session.token']
})
const result = redact(obj)
// Original unchanged
assert.strictEqual(obj.user.password, 'secret')
assert.strictEqual(obj.session.token, 'abc123')
// Result redacted
const parsed = JSON.parse(result)
assert.strictEqual(parsed.user.password, '[REDACTED]')
assert.strictEqual(parsed.session.token, '[REDACTED]')
assert.strictEqual(parsed.user.name, 'john')
})
test('custom censor value', () => {
const obj = { secret: 'hidden' }
const redact = slowRedact({
paths: ['secret'],
censor: '***'
})
const result = redact(obj)
const parsed = JSON.parse(result)
assert.strictEqual(parsed.secret, '***')
})
test('serialize: false returns object with restore method', () => {
const obj = { secret: 'hidden' }
const redact = slowRedact({
paths: ['secret'],
serialize: false
})
const result = redact(obj)
// Should be object, not string
assert.strictEqual(typeof result, 'object')
assert.strictEqual(result.secret, '[REDACTED]')
// Should have restore method
assert.strictEqual(typeof result.restore, 'function')
const restored = result.restore()
assert.strictEqual(restored.secret, 'hidden')
})
test('bracket notation paths', () => {
const obj = {
'weird-key': { 'another-weird': 'secret' },
normal: 'public'
}
const redact = slowRedact({
paths: ['["weird-key"]["another-weird"]']
})
const result = redact(obj)
const parsed = JSON.parse(result)
assert.strictEqual(parsed['weird-key']['another-weird'], '[REDACTED]')
assert.strictEqual(parsed.normal, 'public')
})
test('array paths', () => {
const obj = {
users: [
{ name: 'john', password: 'secret1' },
{ name: 'jane', password: 'secret2' }
]
}
const redact = slowRedact({
paths: ['users[0].password', 'users[1].password']
})
const result = redact(obj)
const parsed = JSON.parse(result)
assert.strictEqual(parsed.users[0].password, '[REDACTED]')
assert.strictEqual(parsed.users[1].password, '[REDACTED]')
assert.strictEqual(parsed.users[0].name, 'john')
assert.strictEqual(parsed.users[1].name, 'jane')
})
test('wildcard at end of path', () => {
const obj = {
secrets: {
key1: 'secret1',
key2: 'secret2'
},
public: 'data'
}
const redact = slowRedact({
paths: ['secrets.*']
})
const result = redact(obj)
const parsed = JSON.parse(result)
assert.strictEqual(parsed.secrets.key1, '[REDACTED]')
assert.strictEqual(parsed.secrets.key2, '[REDACTED]')
assert.strictEqual(parsed.public, 'data')
})
test('wildcard with arrays', () => {
const obj = {
items: ['secret1', 'secret2', 'secret3']
}
const redact = slowRedact({
paths: ['items.*']
})
const result = redact(obj)
const parsed = JSON.parse(result)
assert.strictEqual(parsed.items[0], '[REDACTED]')
assert.strictEqual(parsed.items[1], '[REDACTED]')
assert.strictEqual(parsed.items[2], '[REDACTED]')
})
test('intermediate wildcard', () => {
const obj = {
users: {
user1: { password: 'secret1' },
user2: { password: 'secret2' }
}
}
const redact = slowRedact({
paths: ['users.*.password']
})
const result = redact(obj)
const parsed = JSON.parse(result)
assert.strictEqual(parsed.users.user1.password, '[REDACTED]')
assert.strictEqual(parsed.users.user2.password, '[REDACTED]')
})
test('censor function', () => {
const obj = { secret: 'hidden' }
const redact = slowRedact({
paths: ['secret'],
censor: (value, path) => `REDACTED:${path.join('.')}`
})
const result = redact(obj)
const parsed = JSON.parse(result)
assert.strictEqual(parsed.secret, 'REDACTED:secret')
})
test('custom serialize function', () => {
const obj = { secret: 'hidden', public: 'data' }
const redact = slowRedact({
paths: ['secret'],
serialize: (obj) => `custom:${JSON.stringify(obj)}`
})
const result = redact(obj)
assert(result.startsWith('custom:'))
const parsed = JSON.parse(result.slice(7))
assert.strictEqual(parsed.secret, '[REDACTED]')
assert.strictEqual(parsed.public, 'data')
})
test('nested paths', () => {
const obj = {
level1: {
level2: {
level3: {
secret: 'hidden'
}
}
}
}
const redact = slowRedact({
paths: ['level1.level2.level3.secret']
})
const result = redact(obj)
const parsed = JSON.parse(result)
assert.strictEqual(parsed.level1.level2.level3.secret, '[REDACTED]')
})
test('non-existent paths are ignored', () => {
const obj = { existing: 'value' }
const redact = slowRedact({
paths: ['nonexistent.path']
})
const result = redact(obj)
const parsed = JSON.parse(result)
assert.strictEqual(parsed.existing, 'value')
assert.strictEqual(parsed.nonexistent, undefined)
})
test('null and undefined handling', () => {
const obj = {
nullValue: null,
undefinedValue: undefined,
nested: {
nullValue: null
}
}
const redact = slowRedact({
paths: ['nullValue', 'nested.nullValue']
})
const result = redact(obj)
const parsed = JSON.parse(result)
assert.strictEqual(parsed.nullValue, '[REDACTED]')
assert.strictEqual(parsed.nested.nullValue, '[REDACTED]')
})
test('original object remains unchanged', () => {
const original = {
secret: 'hidden',
nested: { secret: 'hidden2' }
}
const copy = JSON.parse(JSON.stringify(original))
const redact = slowRedact({
paths: ['secret', 'nested.secret']
})
redact(original)
// Original should be completely unchanged
assert.deepStrictEqual(original, copy)
})
test('strict mode with primitives', () => {
const redact = slowRedact({
paths: ['test'],
strict: true
})
const stringResult = redact('primitive')
assert.strictEqual(stringResult, '"primitive"')
const numberResult = redact(42)
assert.strictEqual(numberResult, '42')
})
// Path validation tests to match fast-redact behavior
test('path validation - non-string paths should throw', () => {
assert.throws(() => {
slowRedact({ paths: [123] })
}, {
message: 'Paths must be (non-empty) strings'
})
assert.throws(() => {
slowRedact({ paths: [null] })
}, {
message: 'Paths must be (non-empty) strings'
})
assert.throws(() => {
slowRedact({ paths: [undefined] })
}, {
message: 'Paths must be (non-empty) strings'
})
})
test('path validation - empty string should throw', () => {
assert.throws(() => {
slowRedact({ paths: [''] })
}, {
message: 'Invalid redaction path ()'
})
})
test('path validation - double dots should throw', () => {
assert.throws(() => {
slowRedact({ paths: ['invalid..path'] })
}, {
message: 'Invalid redaction path (invalid..path)'
})
assert.throws(() => {
slowRedact({ paths: ['a..b..c'] })
}, {
message: 'Invalid redaction path (a..b..c)'
})
})
test('path validation - unmatched brackets should throw', () => {
assert.throws(() => {
slowRedact({ paths: ['invalid[unclosed'] })
}, {
message: 'Invalid redaction path (invalid[unclosed)'
})
assert.throws(() => {
slowRedact({ paths: ['invalid]unopened'] })
}, {
message: 'Invalid redaction path (invalid]unopened)'
})
assert.throws(() => {
slowRedact({ paths: ['nested[a[b]'] })
}, {
message: 'Invalid redaction path (nested[a[b])'
})
})
test('path validation - comma-separated paths should throw', () => {
assert.throws(() => {
slowRedact({ paths: ['req,headers.cookie'] })
}, {
message: 'Invalid redaction path (req,headers.cookie)'
})
assert.throws(() => {
slowRedact({ paths: ['user,profile,name'] })
}, {
message: 'Invalid redaction path (user,profile,name)'
})
assert.throws(() => {
slowRedact({ paths: ['a,b'] })
}, {
message: 'Invalid redaction path (a,b)'
})
})
test('path validation - mixed valid and invalid should throw', () => {
assert.throws(() => {
slowRedact({ paths: ['valid.path', 123, 'another.valid'] })
}, {
message: 'Paths must be (non-empty) strings'
})
assert.throws(() => {
slowRedact({ paths: ['valid.path', 'invalid..path'] })
}, {
message: 'Invalid redaction path (invalid..path)'
})
assert.throws(() => {
slowRedact({ paths: ['valid.path', 'req,headers.cookie'] })
}, {
message: 'Invalid redaction path (req,headers.cookie)'
})
})
test('path validation - valid paths should work', () => {
// These should not throw
assert.doesNotThrow(() => {
slowRedact({ paths: [] })
})
assert.doesNotThrow(() => {
slowRedact({ paths: ['valid.path'] })
})
assert.doesNotThrow(() => {
slowRedact({ paths: ['user.password', 'data[0].secret'] })
})
assert.doesNotThrow(() => {
slowRedact({ paths: ['["quoted-key"].value'] })
})
assert.doesNotThrow(() => {
slowRedact({ paths: ["['single-quoted'].value"] })
})
assert.doesNotThrow(() => {
slowRedact({ paths: ['array[0]', 'object.property', 'wildcard.*'] })
})
})
// fast-redact compatibility tests
test('censor function receives path as array (fast-redact compatibility)', () => {
const obj = {
headers: {
authorization: 'Bearer token',
'x-api-key': 'secret-key'
}
}
const pathsReceived = []
const redact = slowRedact({
paths: ['headers.authorization', 'headers["x-api-key"]'],
censor: (value, path) => {
pathsReceived.push(path)
assert(Array.isArray(path), 'Path should be an array')
return '[REDACTED]'
}
})
redact(obj)
// Verify paths are arrays
assert.strictEqual(pathsReceived.length, 2)
assert.deepStrictEqual(pathsReceived[0], ['headers', 'authorization'])
assert.deepStrictEqual(pathsReceived[1], ['headers', 'x-api-key'])
})
test('censor function with nested paths receives correct array', () => {
const obj = {
user: {
profile: {
credentials: {
password: 'secret123'
}
}
}
}
let receivedPath
const redact = slowRedact({
paths: ['user.profile.credentials.password'],
censor: (value, path) => {
receivedPath = path
assert.strictEqual(value, 'secret123')
assert(Array.isArray(path))
return '[REDACTED]'
}
})
redact(obj)
assert.deepStrictEqual(receivedPath, ['user', 'profile', 'credentials', 'password'])
})
test('censor function with wildcards receives correct array paths', () => {
const obj = {
users: {
user1: { password: 'secret1' },
user2: { password: 'secret2' }
}
}
const pathsReceived = []
const redact = slowRedact({
paths: ['users.*.password'],
censor: (value, path) => {
pathsReceived.push([...path]) // copy the array
assert(Array.isArray(path))
return '[REDACTED]'
}
})
redact(obj)
assert.strictEqual(pathsReceived.length, 2)
assert.deepStrictEqual(pathsReceived[0], ['users', 'user1', 'password'])
assert.deepStrictEqual(pathsReceived[1], ['users', 'user2', 'password'])
})
test('censor function with array wildcard receives correct array paths', () => {
const obj = {
items: [
{ secret: 'value1' },
{ secret: 'value2' }
]
}
const pathsReceived = []
const redact = slowRedact({
paths: ['items.*.secret'],
censor: (value, path) => {
pathsReceived.push([...path])
assert(Array.isArray(path))
return '[REDACTED]'
}
})
redact(obj)
assert.strictEqual(pathsReceived.length, 2)
assert.deepStrictEqual(pathsReceived[0], ['items', '0', 'secret'])
assert.deepStrictEqual(pathsReceived[1], ['items', '1', 'secret'])
})
test('censor function with end wildcard receives correct array paths', () => {
const obj = {
secrets: {
key1: 'secret1',
key2: 'secret2'
}
}
const pathsReceived = []
const redact = slowRedact({
paths: ['secrets.*'],
censor: (value, path) => {
pathsReceived.push([...path])
assert(Array.isArray(path))
return '[REDACTED]'
}
})
redact(obj)
assert.strictEqual(pathsReceived.length, 2)
// Sort paths for consistent testing since object iteration order isn't guaranteed
pathsReceived.sort((a, b) => a[1].localeCompare(b[1]))
assert.deepStrictEqual(pathsReceived[0], ['secrets', 'key1'])
assert.deepStrictEqual(pathsReceived[1], ['secrets', 'key2'])
})
test('type safety: accessing properties on primitive values should not throw', () => {
// Test case from GitHub issue #5
const redactor = slowRedact({ paths: ['headers.authorization'] })
const data = {
headers: 123 // primitive value
}
assert.doesNotThrow(() => {
const result = redactor(data)
const parsed = JSON.parse(result)
assert.strictEqual(parsed.headers, 123) // Should remain unchanged
})
// Test wildcards with primitives
const redactor2 = slowRedact({ paths: ['data.*.nested'] })
const data2 = {
data: {
item1: 123, // primitive, trying to access .nested on it
item2: { nested: 'secret' }
}
}
assert.doesNotThrow(() => {
const result2 = redactor2(data2)
const parsed2 = JSON.parse(result2)
assert.strictEqual(parsed2.data.item1, 123) // Primitive unchanged
assert.strictEqual(parsed2.data.item2.nested, '[REDACTED]') // Object property redacted
})
// Test deep nested access on primitives
const redactor3 = slowRedact({ paths: ['user.name.first.charAt'] })
const data3 = {
user: {
name: 'John' // string primitive
}
}
assert.doesNotThrow(() => {
const result3 = redactor3(data3)
const parsed3 = JSON.parse(result3)
assert.strictEqual(parsed3.user.name, 'John') // Should remain unchanged
})
})
// Remove option tests
test('remove option: basic key removal', () => {
const obj = { username: 'john', password: 'secret123' }
const redact = slowRedact({ paths: ['password'], remove: true })
const result = redact(obj)
// Original object should remain unchanged
assert.strictEqual(obj.password, 'secret123')
// Result should have password completely removed
const parsed = JSON.parse(result)
assert.strictEqual(parsed.username, 'john')
assert.strictEqual('password' in parsed, false)
assert.strictEqual(parsed.password, undefined)
})
test('remove option: multiple paths removal', () => {
const obj = {
user: { name: 'john', password: 'secret' },
session: { token: 'abc123', id: 'session1' }
}
const redact = slowRedact({
paths: ['user.password', 'session.token'],
remove: true
})
const result = redact(obj)
// Original unchanged
assert.strictEqual(obj.user.password, 'secret')
assert.strictEqual(obj.session.token, 'abc123')
// Result has keys completely removed
const parsed = JSON.parse(result)
assert.strictEqual(parsed.user.name, 'john')
assert.strictEqual(parsed.session.id, 'session1')
assert.strictEqual('password' in parsed.user, false)
assert.strictEqual('token' in parsed.session, false)
})
test('remove option: wildcard removal', () => {
const obj = {
secrets: {
key1: 'secret1',
key2: 'secret2'
},
public: 'data'
}
const redact = slowRedact({
paths: ['secrets.*'],
remove: true
})
const result = redact(obj)
const parsed = JSON.parse(result)
assert.strictEqual(parsed.public, 'data')
assert.deepStrictEqual(parsed.secrets, {}) // All keys removed
})
test('remove option: array wildcard removal', () => {
const obj = {
items: ['secret1', 'secret2', 'secret3'],
meta: 'data'
}
const redact = slowRedact({
paths: ['items.*'],
remove: true
})
const result = redact(obj)
const parsed = JSON.parse(result)
assert.strictEqual(parsed.meta, 'data')
// Array items set to undefined are omitted by JSON.stringify
assert.deepStrictEqual(parsed.items, [null, null, null])
})
test('remove option: intermediate wildcard removal', () => {
const obj = {
users: {
user1: { password: 'secret1', name: 'john' },
user2: { password: 'secret2', name: 'jane' }
}
}
const redact = slowRedact({
paths: ['users.*.password'],
remove: true
})
const result = redact(obj)
const parsed = JSON.parse(result)
assert.strictEqual(parsed.users.user1.name, 'john')
assert.strictEqual(parsed.users.user2.name, 'jane')
assert.strictEqual('password' in parsed.users.user1, false)
assert.strictEqual('password' in parsed.users.user2, false)
})
test('remove option: serialize false returns object with removed keys', () => {
const obj = { secret: 'hidden', public: 'data' }
const redact = slowRedact({
paths: ['secret'],
remove: true,
serialize: false
})
const result = redact(obj)
// Should be object, not string
assert.strictEqual(typeof result, 'object')
assert.strictEqual(result.public, 'data')
assert.strictEqual('secret' in result, false)
// Should have restore method
assert.strictEqual(typeof result.restore, 'function')
const restored = result.restore()
assert.strictEqual(restored.secret, 'hidden')
})
test('remove option: non-existent paths are ignored', () => {
const obj = { existing: 'value' }
const redact = slowRedact({
paths: ['nonexistent.path'],
remove: true
})
const result = redact(obj)
const parsed = JSON.parse(result)
assert.strictEqual(parsed.existing, 'value')
assert.strictEqual(parsed.nonexistent, undefined)
})
// Test for Issue #13: Empty string bracket notation paths not being redacted correctly
test('empty string bracket notation path', () => {
const obj = { '': { c: 'sensitive-data' } }
const redact = slowRedact({ paths: ["[''].c"] })
const result = redact(obj)
// Original object should remain unchanged
assert.strictEqual(obj[''].c, 'sensitive-data')
// Result should have redacted path
const parsed = JSON.parse(result)
assert.strictEqual(parsed[''].c, '[REDACTED]')
})
test('empty string bracket notation with double quotes', () => {
const obj = { '': { c: 'sensitive-data' } }
const redact = slowRedact({ paths: ['[""].c'] })
const result = redact(obj)
// Original object should remain unchanged
assert.strictEqual(obj[''].c, 'sensitive-data')
// Result should have redacted path
const parsed = JSON.parse(result)
assert.strictEqual(parsed[''].c, '[REDACTED]')
})
test('empty string key with nested bracket notation', () => {
const obj = { '': { '': { secret: 'value' } } }
const redact = slowRedact({ paths: ["[''][''].secret"] })
const result = redact(obj)
// Original object should remain unchanged
assert.strictEqual(obj[''][''].secret, 'value')
// Result should have redacted path
const parsed = JSON.parse(result)
assert.strictEqual(parsed[''][''].secret, '[REDACTED]')
})
// Test for Pino issue #2313: censor should only be called when path exists
test('censor function not called for non-existent paths', () => {
let censorCallCount = 0
const censorCalls = []
const redact = slowRedact({
paths: ['a.b.c', 'req.authorization', 'url'],
serialize: false,
censor (value, path) {
censorCallCount++
censorCalls.push({ value, path: path.slice() })
return '***'
}
})
// Test case 1: { req: { id: 'test' } }
// req.authorization doesn't exist, censor should not be called for it
censorCallCount = 0
censorCalls.length = 0
redact({ req: { id: 'test' } })
// Should not have been called for any path since none exist
assert.strictEqual(censorCallCount, 0, 'censor should not be called when paths do not exist')
// Test case 2: { a: { d: 'test' } }
// a.b.c doesn't exist (a.d exists, but not a.b.c)
censorCallCount = 0
redact({ a: { d: 'test' } })
assert.strictEqual(censorCallCount, 0)
// Test case 3: paths that do exist should still call censor
censorCallCount = 0
censorCalls.length = 0
const result = redact({ req: { authorization: 'bearer token' } })
assert.strictEqual(censorCallCount, 1, 'censor should be called when path exists')
assert.deepStrictEqual(censorCalls[0].path, ['req', 'authorization'])
assert.strictEqual(censorCalls[0].value, 'bearer token')
assert.strictEqual(result.req.authorization, '***')
})
test('censor function not called for non-existent nested paths', () => {
let censorCallCount = 0
const redact = slowRedact({
paths: ['headers.authorization'],
serialize: false,
censor (value, path) {
censorCallCount++
return '[REDACTED]'
}
})
// headers exists but authorization doesn't
censorCallCount = 0
const result1 = redact({ headers: { 'content-type': 'application/json' } })
assert.strictEqual(censorCallCount, 0)
assert.deepStrictEqual(result1.headers, { 'content-type': 'application/json' })
// headers doesn't exist at all
censorCallCount = 0
const result2 = redact({ body: 'data' })
assert.strictEqual(censorCallCount, 0)
assert.strictEqual(result2.body, 'data')
assert.strictEqual(typeof result2.restore, 'function')
// headers.authorization exists - should call censor
censorCallCount = 0
const result3 = redact({ headers: { authorization: 'Bearer token' } })
assert.strictEqual(censorCallCount, 1)
assert.strictEqual(result3.headers.authorization, '[REDACTED]')
})
+390
View File
@@ -0,0 +1,390 @@
const { test } = require('node:test')
const { strict: assert } = require('node:assert')
const slowRedact = require('../index.js')
const fastRedact = require('fast-redact')
test('integration: basic path redaction matches fast-redact', () => {
const obj = {
headers: {
cookie: 'secret-cookie',
authorization: 'Bearer token'
},
body: { message: 'hello' }
}
const slowResult = slowRedact({ paths: ['headers.cookie'] })(obj)
const fastResult = fastRedact({ paths: ['headers.cookie'] })(obj)
assert.strictEqual(slowResult, fastResult)
})
test('integration: multiple paths match fast-redact', () => {
const obj = {
user: { name: 'john', password: 'secret' },
session: { token: 'abc123' }
}
const paths = ['user.password', 'session.token']
const slowResult = slowRedact({ paths })(obj)
const fastResult = fastRedact({ paths })(obj)
assert.strictEqual(slowResult, fastResult)
})
test('integration: custom censor value matches fast-redact', () => {
const obj = { secret: 'hidden' }
const options = { paths: ['secret'], censor: '***' }
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
assert.strictEqual(slowResult, fastResult)
})
test('integration: bracket notation matches fast-redact', () => {
const obj = {
'weird-key': { 'another-weird': 'secret' },
normal: 'public'
}
const options = { paths: ['["weird-key"]["another-weird"]'] }
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
assert.strictEqual(slowResult, fastResult)
})
test('integration: array paths match fast-redact', () => {
const obj = {
users: [
{ name: 'john', password: 'secret1' },
{ name: 'jane', password: 'secret2' }
]
}
const options = { paths: ['users[0].password', 'users[1].password'] }
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
assert.strictEqual(slowResult, fastResult)
})
test('integration: wildcard at end matches fast-redact', () => {
const obj = {
secrets: {
key1: 'secret1',
key2: 'secret2'
},
public: 'data'
}
const options = { paths: ['secrets.*'] }
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
assert.strictEqual(slowResult, fastResult)
})
test('integration: wildcard with arrays matches fast-redact', () => {
const obj = {
items: ['secret1', 'secret2', 'secret3']
}
const options = { paths: ['items.*'] }
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
assert.strictEqual(slowResult, fastResult)
})
test('integration: intermediate wildcard matches fast-redact', () => {
const obj = {
users: {
user1: { password: 'secret1' },
user2: { password: 'secret2' }
}
}
const options = { paths: ['users.*.password'] }
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
assert.strictEqual(slowResult, fastResult)
})
test('integration: custom serialize function matches fast-redact', () => {
const obj = { secret: 'hidden', public: 'data' }
const options = {
paths: ['secret'],
serialize: (obj) => `custom:${JSON.stringify(obj)}`
}
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
assert.strictEqual(slowResult, fastResult)
})
test('integration: nested paths match fast-redact', () => {
const obj = {
level1: {
level2: {
level3: {
secret: 'hidden'
}
}
}
}
const options = { paths: ['level1.level2.level3.secret'] }
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
assert.strictEqual(slowResult, fastResult)
})
test('integration: non-existent paths match fast-redact', () => {
const obj = { existing: 'value' }
const options = { paths: ['nonexistent.path'] }
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
assert.strictEqual(slowResult, fastResult)
})
test('integration: null and undefined handling - legitimate difference', () => {
const obj = {
nullValue: null,
undefinedValue: undefined,
nested: {
nullValue: null
}
}
const options = { paths: ['nullValue', 'nested.nullValue'] }
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
// This is a legitimate behavioral difference:
// @pinojs/redact redacts null values, fast-redact doesn't
const slowParsed = JSON.parse(slowResult)
const fastParsed = JSON.parse(fastResult)
// @pinojs/redact redacts nulls
assert.strictEqual(slowParsed.nullValue, '[REDACTED]')
assert.strictEqual(slowParsed.nested.nullValue, '[REDACTED]')
// fast-redact preserves nulls
assert.strictEqual(fastParsed.nullValue, null)
assert.strictEqual(fastParsed.nested.nullValue, null)
})
test('integration: strict mode with primitives - different error handling', () => {
const options = { paths: ['test'], strict: true }
const slowRedactFn = slowRedact(options)
const fastRedactFn = fastRedact(options)
// @pinojs/redact handles primitives gracefully
const stringSlowResult = slowRedactFn('primitive')
assert.strictEqual(stringSlowResult, '"primitive"')
const numberSlowResult = slowRedactFn(42)
assert.strictEqual(numberSlowResult, '42')
// fast-redact throws an error for primitives in strict mode
assert.throws(() => {
fastRedactFn('primitive')
}, /primitives cannot be redacted/)
assert.throws(() => {
fastRedactFn(42)
}, /primitives cannot be redacted/)
})
test('integration: serialize false behavior difference', () => {
const slowObj = { secret: 'hidden' }
const fastObj = { secret: 'hidden' }
const options = { paths: ['secret'], serialize: false }
const slowResult = slowRedact(options)(slowObj)
const fastResult = fastRedact(options)(fastObj)
// Both should redact the secret
assert.strictEqual(slowResult.secret, '[REDACTED]')
assert.strictEqual(fastResult.secret, '[REDACTED]')
// @pinojs/redact always has restore method
assert.strictEqual(typeof slowResult.restore, 'function')
// @pinojs/redact should restore to original value
assert.strictEqual(slowResult.restore().secret, 'hidden')
// Key difference: original object state
// fast-redact mutates the original, @pinojs/redact doesn't
assert.strictEqual(slowObj.secret, 'hidden') // @pinojs/redact preserves original
assert.strictEqual(fastObj.secret, '[REDACTED]') // fast-redact mutates original
})
test('integration: censor function behavior', () => {
const obj = { secret: 'hidden' }
const options = {
paths: ['secret'],
censor: (value, path) => `REDACTED:${path}`
}
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
assert.strictEqual(slowResult, fastResult)
})
test('integration: complex object with mixed patterns', () => {
const obj = {
users: [
{
id: 1,
name: 'john',
credentials: { password: 'secret1', apiKey: 'key1' }
},
{
id: 2,
name: 'jane',
credentials: { password: 'secret2', apiKey: 'key2' }
}
],
config: {
database: { password: 'db-secret' },
api: { keys: ['key1', 'key2', 'key3'] }
}
}
const options = {
paths: [
'users.*.credentials.password',
'users.*.credentials.apiKey',
'config.database.password',
'config.api.keys.*'
]
}
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
assert.strictEqual(slowResult, fastResult)
})
// Remove option integration tests - comparing with fast-redact
test('integration: remove option basic comparison with fast-redact', () => {
const obj = { username: 'john', password: 'secret123' }
const options = { paths: ['password'], remove: true }
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
assert.strictEqual(slowResult, fastResult)
// Verify the key is actually removed
const parsed = JSON.parse(slowResult)
assert.strictEqual(parsed.username, 'john')
assert.strictEqual('password' in parsed, false)
})
test('integration: remove option multiple paths comparison with fast-redact', () => {
const obj = {
user: { name: 'john', password: 'secret' },
session: { token: 'abc123', id: 'session1' }
}
const options = {
paths: ['user.password', 'session.token'],
remove: true
}
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
assert.strictEqual(slowResult, fastResult)
})
test('integration: remove option wildcard comparison with fast-redact', () => {
const obj = {
secrets: {
key1: 'secret1',
key2: 'secret2'
},
public: 'data'
}
const options = {
paths: ['secrets.*'],
remove: true
}
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
assert.strictEqual(slowResult, fastResult)
})
test('integration: remove option intermediate wildcard comparison with fast-redact', () => {
const obj = {
users: {
user1: { password: 'secret1', name: 'john' },
user2: { password: 'secret2', name: 'jane' }
}
}
const options = {
paths: ['users.*.password'],
remove: true
}
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
assert.strictEqual(slowResult, fastResult)
})
test('integration: remove option with custom censor comparison with fast-redact', () => {
const obj = { secret: 'hidden', public: 'data' }
const options = {
paths: ['secret'],
censor: '***',
remove: true
}
const slowResult = slowRedact(options)(obj)
const fastResult = fastRedact(options)(obj)
assert.strictEqual(slowResult, fastResult)
// With remove: true, censor value should be ignored
const parsed = JSON.parse(slowResult)
assert.strictEqual('secret' in parsed, false)
assert.strictEqual(parsed.public, 'data')
})
test('integration: remove option serialize false behavior - @pinojs/redact only', () => {
// fast-redact doesn't support remove option with serialize: false
// so we test @pinojs/redact's behavior only
const obj = { secret: 'hidden', public: 'data' }
const options = { paths: ['secret'], remove: true, serialize: false }
const result = slowRedact(options)(obj)
// Should have the key removed
assert.strictEqual('secret' in result, false)
assert.strictEqual(result.public, 'data')
// Should have restore method
assert.strictEqual(typeof result.restore, 'function')
// Original object should be preserved
assert.strictEqual(obj.secret, 'hidden')
// Restore should bring back the removed key
const restored = result.restore()
assert.strictEqual(restored.secret, 'hidden')
})
+227
View File
@@ -0,0 +1,227 @@
'use strict'
const { test } = require('node:test')
const { strict: assert } = require('node:assert')
const slowRedact = require('../index.js')
// Tests for Issue #2319: @pinojs/redact fails to redact patterns with 3+ consecutive wildcards
test('three consecutive wildcards: *.*.*.password (4 levels deep)', () => {
const obj = {
simple: { password: 'secret-2-levels' },
user: { auth: { password: 'secret-3-levels' } },
nested: { deep: { auth: { password: 'secret-4-levels' } } }
}
const redact = slowRedact({
paths: ['*.*.*.password']
})
const result = redact(obj)
const parsed = JSON.parse(result)
// Only the 4-level deep password should be redacted
assert.strictEqual(parsed.simple.password, 'secret-2-levels', '2-level password should NOT be redacted')
assert.strictEqual(parsed.user.auth.password, 'secret-3-levels', '3-level password should NOT be redacted')
assert.strictEqual(parsed.nested.deep.auth.password, '[REDACTED]', '4-level password SHOULD be redacted')
})
test('four consecutive wildcards: *.*.*.*.password (5 levels deep)', () => {
const obj = {
simple: { password: 'secret-2-levels' },
user: { auth: { password: 'secret-3-levels' } },
nested: { deep: { auth: { password: 'secret-4-levels' } } },
config: { user: { auth: { settings: { password: 'secret-5-levels' } } } }
}
const redact = slowRedact({
paths: ['*.*.*.*.password']
})
const result = redact(obj)
const parsed = JSON.parse(result)
// Only the 5-level deep password should be redacted
assert.strictEqual(parsed.simple.password, 'secret-2-levels', '2-level password should NOT be redacted')
assert.strictEqual(parsed.user.auth.password, 'secret-3-levels', '3-level password should NOT be redacted')
assert.strictEqual(parsed.nested.deep.auth.password, 'secret-4-levels', '4-level password should NOT be redacted')
assert.strictEqual(parsed.config.user.auth.settings.password, '[REDACTED]', '5-level password SHOULD be redacted')
})
test('five consecutive wildcards: *.*.*.*.*.password (6 levels deep)', () => {
const obj = {
simple: { password: 'secret-2-levels' },
user: { auth: { password: 'secret-3-levels' } },
nested: { deep: { auth: { password: 'secret-4-levels' } } },
config: { user: { auth: { settings: { password: 'secret-5-levels' } } } },
data: {
reqConfig: {
data: {
credentials: {
settings: {
password: 'secret-6-levels'
}
}
}
}
}
}
const redact = slowRedact({
paths: ['*.*.*.*.*.password']
})
const result = redact(obj)
const parsed = JSON.parse(result)
// Only the 6-level deep password should be redacted
assert.strictEqual(parsed.simple.password, 'secret-2-levels', '2-level password should NOT be redacted')
assert.strictEqual(parsed.user.auth.password, 'secret-3-levels', '3-level password should NOT be redacted')
assert.strictEqual(parsed.nested.deep.auth.password, 'secret-4-levels', '4-level password should NOT be redacted')
assert.strictEqual(parsed.config.user.auth.settings.password, 'secret-5-levels', '5-level password should NOT be redacted')
assert.strictEqual(parsed.data.reqConfig.data.credentials.settings.password, '[REDACTED]', '6-level password SHOULD be redacted')
})
test('three wildcards with censor function receives correct values', () => {
const obj = {
nested: { deep: { auth: { password: 'secret-value' } } }
}
const censorCalls = []
const redact = slowRedact({
paths: ['*.*.*.password'],
censor: (value, path) => {
censorCalls.push({ value, path: [...path] })
return '[REDACTED]'
}
})
const result = redact(obj)
const parsed = JSON.parse(result)
// Should have been called exactly once with the correct value
assert.strictEqual(censorCalls.length, 1, 'censor should be called once')
assert.strictEqual(censorCalls[0].value, 'secret-value', 'censor should receive the actual value')
assert.deepStrictEqual(censorCalls[0].path, ['nested', 'deep', 'auth', 'password'], 'censor should receive correct path')
assert.strictEqual(parsed.nested.deep.auth.password, '[REDACTED]')
})
test('three wildcards with multiple matches', () => {
const obj = {
api1: { v1: { auth: { token: 'token1' } } },
api2: { v2: { auth: { token: 'token2' } } },
api3: { v1: { auth: { token: 'token3' } } }
}
const redact = slowRedact({
paths: ['*.*.*.token']
})
const result = redact(obj)
const parsed = JSON.parse(result)
// All three tokens should be redacted
assert.strictEqual(parsed.api1.v1.auth.token, '[REDACTED]')
assert.strictEqual(parsed.api2.v2.auth.token, '[REDACTED]')
assert.strictEqual(parsed.api3.v1.auth.token, '[REDACTED]')
})
test('three wildcards with remove option', () => {
const obj = {
nested: { deep: { auth: { password: 'secret', username: 'admin' } } }
}
const redact = slowRedact({
paths: ['*.*.*.password'],
remove: true
})
const result = redact(obj)
const parsed = JSON.parse(result)
// Password should be removed entirely
assert.strictEqual('password' in parsed.nested.deep.auth, false, 'password key should be removed')
assert.strictEqual(parsed.nested.deep.auth.username, 'admin', 'username should remain')
})
test('mixed: two and three wildcards in same redactor', () => {
const obj = {
user: { auth: { password: 'secret-3-levels' } },
config: { deep: { auth: { password: 'secret-4-levels' } } }
}
const redact = slowRedact({
paths: ['*.*.password', '*.*.*.password']
})
const result = redact(obj)
const parsed = JSON.parse(result)
// Both should be redacted
assert.strictEqual(parsed.user.auth.password, '[REDACTED]', '3-level should be redacted by *.*.password')
assert.strictEqual(parsed.config.deep.auth.password, '[REDACTED]', '4-level should be redacted by *.*.*.password')
})
test('three wildcards should not call censor for non-existent paths', () => {
const obj = {
shallow: { data: 'value' },
nested: { deep: { auth: { password: 'secret' } } }
}
let censorCallCount = 0
const redact = slowRedact({
paths: ['*.*.*.password'],
censor: (value, path) => {
censorCallCount++
return '[REDACTED]'
}
})
redact(obj)
// Should only be called once for the path that exists
assert.strictEqual(censorCallCount, 1, 'censor should only be called for existing paths')
})
test('three wildcards with arrays', () => {
const obj = {
users: [
{ auth: { password: 'secret1' } },
{ auth: { password: 'secret2' } }
]
}
const redact = slowRedact({
paths: ['*.*.*.password']
})
const result = redact(obj)
const parsed = JSON.parse(result)
// Both passwords should be redacted (users[0].auth.password is 4 levels)
assert.strictEqual(parsed.users[0].auth.password, '[REDACTED]')
assert.strictEqual(parsed.users[1].auth.password, '[REDACTED]')
})
test('four wildcards with authorization header (real-world case)', () => {
const obj = {
requests: {
api1: {
config: {
headers: {
authorization: 'Bearer secret-token'
}
}
},
api2: {
config: {
headers: {
authorization: 'Bearer another-token'
}
}
}
}
}
const redact = slowRedact({
paths: ['*.*.*.*.authorization']
})
const result = redact(obj)
const parsed = JSON.parse(result)
// Both authorization headers should be redacted
assert.strictEqual(parsed.requests.api1.config.headers.authorization, '[REDACTED]')
assert.strictEqual(parsed.requests.api2.config.headers.authorization, '[REDACTED]')
})
+223
View File
@@ -0,0 +1,223 @@
const { test } = require('node:test')
const { strict: assert } = require('node:assert')
const slowRedact = require('../index.js')
/* eslint-disable no-proto */
test('prototype pollution: __proto__ path should not pollute Object prototype', () => {
const obj = {
user: { name: 'john' },
__proto__: { isAdmin: true }
}
const redact = slowRedact({
paths: ['__proto__.isAdmin'],
serialize: false
})
const result = redact(obj)
// Should not pollute Object.prototype
assert.strictEqual(Object.prototype.isAdmin, undefined)
assert.strictEqual({}.isAdmin, undefined)
// Should redact the __proto__ property if it exists as a regular property
assert.strictEqual(result.__proto__.isAdmin, '[REDACTED]')
})
test('prototype pollution: constructor.prototype path should not pollute', () => {
const obj = {
user: { name: 'john' },
constructor: {
prototype: { isAdmin: true }
}
}
const redact = slowRedact({
paths: ['constructor.prototype.isAdmin'],
serialize: false
})
const result = redact(obj)
// Should not pollute Object.prototype
assert.strictEqual(Object.prototype.isAdmin, undefined)
assert.strictEqual({}.isAdmin, undefined)
// Should redact the constructor.prototype property if it exists as a regular property
assert.strictEqual(result.constructor.prototype.isAdmin, '[REDACTED]')
})
test('prototype pollution: nested __proto__ should not pollute', () => {
const obj = {
user: {
settings: {
__proto__: { isAdmin: true }
}
}
}
const redact = slowRedact({
paths: ['user.settings.__proto__.isAdmin'],
serialize: false
})
const result = redact(obj)
// Should not pollute Object.prototype
assert.strictEqual(Object.prototype.isAdmin, undefined)
assert.strictEqual({}.isAdmin, undefined)
// Should redact the nested __proto__ property
assert.strictEqual(result.user.settings.__proto__.isAdmin, '[REDACTED]')
})
test('prototype pollution: bracket notation __proto__ should not pollute', () => {
const obj = {
user: { name: 'john' },
__proto__: { isAdmin: true }
}
const redact = slowRedact({
paths: ['["__proto__"]["isAdmin"]'],
serialize: false
})
const result = redact(obj)
// Should not pollute Object.prototype
assert.strictEqual(Object.prototype.isAdmin, undefined)
assert.strictEqual({}.isAdmin, undefined)
// Should redact the __proto__ property when accessed via bracket notation
assert.strictEqual(result.__proto__.isAdmin, '[REDACTED]')
})
test('prototype pollution: wildcard with __proto__ should not pollute', () => {
const obj = {
users: {
__proto__: { isAdmin: true },
user1: { name: 'john' },
user2: { name: 'jane' }
}
}
const redact = slowRedact({
paths: ['users.*'],
serialize: false
})
const result = redact(obj)
// Should not pollute Object.prototype
assert.strictEqual(Object.prototype.isAdmin, undefined)
assert.strictEqual({}.isAdmin, undefined)
// Should redact only own properties
assert.strictEqual(result.users.user1, '[REDACTED]')
assert.strictEqual(result.users.user2, '[REDACTED]')
// __proto__ should only be redacted if it's an own property, not inherited
if (Object.prototype.hasOwnProperty.call(obj.users, '__proto__')) {
assert.strictEqual(result.users.__proto__, '[REDACTED]')
}
})
test('prototype pollution: malicious JSON payload should not pollute', () => {
// Simulate a malicious payload that might come from JSON.parse
const maliciousObj = JSON.parse('{"user": {"name": "john"}, "__proto__": {"isAdmin": true}}')
const redact = slowRedact({
paths: ['__proto__.isAdmin'],
serialize: false
})
const result = redact(maliciousObj)
// Should not pollute Object.prototype
assert.strictEqual(Object.prototype.isAdmin, undefined)
assert.strictEqual({}.isAdmin, undefined)
// The malicious payload should have been redacted
assert.strictEqual(result.__proto__.isAdmin, '[REDACTED]')
})
test('prototype pollution: verify prototype chain is preserved', () => {
function CustomClass () {
this.data = 'test'
}
CustomClass.prototype.method = function () { return 'original' }
const obj = new CustomClass()
const redact = slowRedact({
paths: ['data'],
serialize: false
})
const result = redact(obj)
// Should redact the data property
assert.strictEqual(result.data, '[REDACTED]')
// Should preserve the original prototype chain
assert.strictEqual(result.method(), 'original')
assert.strictEqual(Object.getPrototypeOf(result), CustomClass.prototype)
})
test('prototype pollution: setValue should not create prototype pollution', () => {
const obj = { user: { name: 'john' } }
// Try to pollute via non-existent path that could create __proto__
const redact = slowRedact({
paths: ['__proto__.isAdmin'],
serialize: false
})
const result = redact(obj)
// Should not pollute Object.prototype
assert.strictEqual(Object.prototype.isAdmin, undefined)
assert.strictEqual({}.isAdmin, undefined)
// Should not create the path if it doesn't exist
// The __proto__ property may exist due to Object.create, but should not contain our redacted value
if (result.__proto__) {
assert.strictEqual(result.__proto__.isAdmin, undefined)
}
})
test('prototype pollution: deep nested prototype properties should not pollute', () => {
const obj = {
level1: {
level2: {
level3: {
__proto__: { isAdmin: true },
constructor: {
prototype: { isEvil: true }
}
}
}
}
}
const redact = slowRedact({
paths: [
'level1.level2.level3.__proto__.isAdmin',
'level1.level2.level3.constructor.prototype.isEvil'
],
serialize: false
})
const result = redact(obj)
// Should not pollute Object.prototype
assert.strictEqual(Object.prototype.isAdmin, undefined)
assert.strictEqual(Object.prototype.isEvil, undefined)
assert.strictEqual({}.isAdmin, undefined)
assert.strictEqual({}.isEvil, undefined)
// Should redact the deep nested properties
assert.strictEqual(result.level1.level2.level3.__proto__.isAdmin, '[REDACTED]')
assert.strictEqual(result.level1.level2.level3.constructor.prototype.isEvil, '[REDACTED]')
})
+115
View File
@@ -0,0 +1,115 @@
const { test } = require('node:test')
const { strict: assert } = require('node:assert')
const slowRedact = require('../index.js')
test('selective cloning shares references for non-redacted paths', () => {
const sharedObject = { unchanged: 'data' }
const obj = {
toRedact: 'secret',
shared: sharedObject,
nested: {
toRedact: 'secret2',
shared: sharedObject
}
}
const redact = slowRedact({
paths: ['toRedact', 'nested.toRedact'],
serialize: false
})
const result = redact(obj)
// Redacted values should be different
assert.strictEqual(result.toRedact, '[REDACTED]')
assert.strictEqual(result.nested.toRedact, '[REDACTED]')
// Non-redacted references should be shared (same object reference)
assert.strictEqual(result.shared, obj.shared)
assert.strictEqual(result.nested.shared, obj.nested.shared)
// The shared object should be the exact same reference
assert.strictEqual(result.shared, sharedObject)
assert.strictEqual(result.nested.shared, sharedObject)
})
test('selective cloning works with arrays', () => {
const sharedItem = { unchanged: 'data' }
const obj = {
items: [
{ secret: 'hidden1', shared: sharedItem },
{ secret: 'hidden2', shared: sharedItem },
sharedItem
]
}
const redact = slowRedact({
paths: ['items.*.secret'],
serialize: false
})
const result = redact(obj)
// Secrets should be redacted
assert.strictEqual(result.items[0].secret, '[REDACTED]')
assert.strictEqual(result.items[1].secret, '[REDACTED]')
// Shared references should be preserved where possible
// Note: array items with secrets will be cloned, but their shared properties should still reference the original
assert.strictEqual(result.items[0].shared, sharedItem)
assert.strictEqual(result.items[1].shared, sharedItem)
// The third item gets cloned due to wildcard, but should have the same content
assert.deepStrictEqual(result.items[2], sharedItem)
// Note: Due to wildcard '*', all array items are cloned, even if they don't need redaction
// This is still a significant optimization for object properties that aren't in wildcard paths
})
test('selective cloning with no paths returns original object', () => {
const obj = { data: 'unchanged' }
const redact = slowRedact({
paths: [],
serialize: false
})
const result = redact(obj)
// Should return the exact same object reference
assert.strictEqual(result, obj)
})
test('selective cloning performance - large objects with minimal redaction', () => {
// Create a large object with mostly shared data
const sharedData = { large: 'data'.repeat(1000) }
const obj = {
secret: 'hidden',
shared1: sharedData,
shared2: sharedData,
nested: {
secret: 'hidden2',
shared3: sharedData,
deep: {
shared4: sharedData,
moreShared: sharedData
}
}
}
const redact = slowRedact({
paths: ['secret', 'nested.secret'],
serialize: false
})
const result = redact(obj)
// Verify redaction worked
assert.strictEqual(result.secret, '[REDACTED]')
assert.strictEqual(result.nested.secret, '[REDACTED]')
// Verify shared references are preserved
assert.strictEqual(result.shared1, sharedData)
assert.strictEqual(result.shared2, sharedData)
assert.strictEqual(result.nested.shared3, sharedData)
assert.strictEqual(result.nested.deep.shared4, sharedData)
assert.strictEqual(result.nested.deep.moreShared, sharedData)
})
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"module": "commonjs",
"lib": [
"es6"
],
"noImplicitAny": true,
"noImplicitThis": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"types": [],
"noEmit": true,
"forceConsistentCasingInFileNames": true
},
"files": [
"index.d.ts",
"index.test-d.ts"
]
}
+11
View File
@@ -0,0 +1,11 @@
language: node_js
sudo: false
node_js:
- 6
- 8
- 10
- 11
- 12
- 13
script:
- npm run ci
+22
View File
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2020 David Mark Clements
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
OR OTHER DEALINGS IN THE SOFTWARE.
+38
View File
@@ -0,0 +1,38 @@
'use strict'
/* global SharedArrayBuffer, Atomics */
if (typeof SharedArrayBuffer !== 'undefined' && typeof Atomics !== 'undefined') {
const nil = new Int32Array(new SharedArrayBuffer(4))
function sleep (ms) {
// also filters out NaN, non-number types, including empty strings, but allows bigints
const valid = ms > 0 && ms < Infinity
if (valid === false) {
if (typeof ms !== 'number' && typeof ms !== 'bigint') {
throw TypeError('sleep: ms must be a number')
}
throw RangeError('sleep: ms must be a number that is greater than 0 but less than Infinity')
}
Atomics.wait(nil, 0, 0, Number(ms))
}
module.exports = sleep
} else {
function sleep (ms) {
// also filters out NaN, non-number types, including empty strings, but allows bigints
const valid = ms > 0 && ms < Infinity
if (valid === false) {
if (typeof ms !== 'number' && typeof ms !== 'bigint') {
throw TypeError('sleep: ms must be a number')
}
throw RangeError('sleep: ms must be a number that is greater than 0 but less than Infinity')
}
const target = Date.now() + Number(ms)
while (target > Date.now()){}
}
module.exports = sleep
}
+37
View File
@@ -0,0 +1,37 @@
{
"name": "atomic-sleep",
"version": "1.0.0",
"description": "Zero CPU overhead, zero dependency, true event-loop blocking sleep",
"main": "index.js",
"scripts": {
"test": "tap -R classic- -j1 test",
"lint": "standard",
"ci": "npm run lint && npm test"
},
"keywords": [
"sleep",
"pause",
"wait",
"performance",
"atomics"
],
"engines": {
"node": ">=8.0.0"
},
"author": "David Mark Clements (@davidmarkclem)",
"license": "MIT",
"devDependencies": {
"standard": "^14.3.1",
"tap": "^14.10.6",
"tape": "^4.13.2"
},
"dependencies": {},
"repository": {
"type": "git",
"url": "git+https://github.com/davidmarkclements/atomic-sleep.git"
},
"bugs": {
"url": "https://github.com/davidmarkclements/atomic-sleep/issues"
},
"homepage": "https://github.com/davidmarkclements/atomic-sleep#readme"
}
+58
View File
@@ -0,0 +1,58 @@
<h1 align="center">Welcome to atomic-sleep ⏱️</h1>
<p>
<img alt="Version" src="https://img.shields.io/badge/version-1.0.0-blue.svg?cacheSeconds=2592000" />
<a href="#" target="_blank">
<img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" />
</a>
<a href="https://twitter.com/davidmarkclem" target="_blank">
<img alt="Twitter: davidmarkclem" src="https://img.shields.io/twitter/follow/davidmarkclem.svg?style=social" />
</a>
</p>
> Zero CPU overhead, zero dependency, true event-loop blocking sleep
## Usage
```js
const sleep = require('atomic-sleep')
console.time('sleep')
setTimeout(() => { console.timeEnd('sleep') }, 100)
sleep(1000)
```
The `console.time` will report a time of just over 1000ms despite the `setTimeout`
being 100ms. This is because the event loop is paused for 1000ms and the setTimeout
fires immediately after the event loop is no longer blocked (as more than 100ms have passed).
## Install
```sh
npm install
```
## Run tests
```sh
npm test
```
## Support
Node and Browser versions that support both `SharedArrayBuffer` and `Atomics` will have (virtually) zero CPU overhead sleep.
For Node, Atomic Sleep can provide zero CPU overhead sleep from Node 8 and up.
For browser support see https://caniuse.com/#feat=sharedarraybuffer and https://caniuse.com/#feat=mdn-javascript_builtins_atomics.
For older Node versions and olders browsers we fall back to blocking the event loop in a way that will cause a CPU spike.
## Author
👤 **David Mark Clements (@davidmarkclem)**
* Twitter: [@davidmarkclem](https://twitter.com/davidmarkclem)
* Github: [@davidmarkclements](https://github.com/davidmarkclements)
+47
View File
@@ -0,0 +1,47 @@
'use strict'
const test = require('tape')
const sleep = require('.')
test('blocks event loop for given amount of milliseconds', ({ is, end }) => {
const now = Date.now()
setTimeout(() => {
const delta = Date.now() - now
const fuzzyDelta = Math.floor(delta / 10) * 10 // allow up to 10ms of execution lag
is(fuzzyDelta, 1000)
end()
}, 100)
sleep(1000)
})
if (typeof BigInt !== 'undefined') {
test('allows ms to be supplied as a BigInt number', ({ is, end }) => {
const now = Date.now()
setTimeout(() => {
const delta = Date.now() - now
const fuzzyDelta = Math.floor(delta / 10) * 10 // allow up to 10ms of execution lag
is(fuzzyDelta, 1000)
end()
}, 100)
sleep(BigInt(1000)) // avoiding n notation as this will error on legacy node/browsers
})
}
test('throws range error if ms less than 0', ({ throws, end }) => {
throws(() => sleep(-1), RangeError('sleep: ms must be a number that is greater than 0 but less than Infinity'))
end()
})
test('throws range error if ms is Infinity', ({ throws, end }) => {
throws(() => sleep(Infinity), RangeError('sleep: ms must be a number that is greater than 0 but less than Infinity'))
end()
})
test('throws range error if ms is not a number or bigint', ({ throws, end }) => {
throws(() => sleep('Infinity'), TypeError('sleep: ms must be a number'))
throws(() => sleep('foo'), TypeError('sleep: ms must be a number'))
throws(() => sleep({a: 1}), TypeError('sleep: ms must be a number'))
throws(() => sleep([1,2,3]), TypeError('sleep: ms must be a number'))
end()
})
+12
View File
@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: '/'
schedule:
interval: daily
open-pull-requests-limit: 10
- package-ecosystem: npm
directory: '/'
schedule:
interval: daily
open-pull-requests-limit: 10
+46
View File
@@ -0,0 +1,46 @@
name: CI
on:
push:
paths-ignore:
- 'docs/**'
- '*.md'
pull_request:
paths-ignore:
- 'docs/**'
- '*.md'
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
node-version: [14, 16, 18, 20]
os: [macos-latest, ubuntu-latest, windows-latest]
exclude:
- node-version: 14
os: windows-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Install
run: |
npm install --ignore-scripts
- name: Run tests
run: |
npm run test
automerge:
needs: test
runs-on: ubuntu-latest
steps:
- uses: fastify/github-action-merge-dependabot@v3.9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Matteo Collina
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+54
View File
@@ -0,0 +1,54 @@
# on-exit-leak-free
This module helps dispose of an object gracefully when the Node.js process exits.
It executes a function with a given parameter
on [`'exit'`](https://nodejs.org/api/process.html#event-exit) without leaking memory,
cleaning things up appropriately if the object is garbage collected.
Requires `WeakRef` and `FinalizationRegistry`, i.e. use Node v14+.
## Install
```bash
npm i on-exit-leak-free
```
## Example
```js
'use strict'
const { register, unregister } = require('on-exit-leak-free')
const assert = require('assert')
function setup () {
// This object can be safely garbage collected,
// and the resulting shutdown function will not be called.
// There are no leaks.
const obj = { foo: 'bar' }
register(obj, shutdown)
// use registerBeforeExit(obj, shutdown) to execute the function only
// on beforeExit
// call unregister(obj) to remove
}
let shutdownCalled = false
// Please make sure that the function passed to register()
// does not create a closure around unnecessary objects.
function shutdown (obj, eventName) {
console.log(eventName) // beforeExit
shutdownCalled = true
assert.strictEqual(obj.foo, 'bar')
}
setup()
process.on('exit', function () {
assert.strictEqual(shutdownCalled, true)
})
```
## License
MIT
+108
View File
@@ -0,0 +1,108 @@
'use strict'
const refs = {
exit: [],
beforeExit: []
}
const functions = {
exit: onExit,
beforeExit: onBeforeExit
}
let registry
function ensureRegistry () {
if (registry === undefined) {
registry = new FinalizationRegistry(clear)
}
}
function install (event) {
if (refs[event].length > 0) {
return
}
process.on(event, functions[event])
}
function uninstall (event) {
if (refs[event].length > 0) {
return
}
process.removeListener(event, functions[event])
if (refs.exit.length === 0 && refs.beforeExit.length === 0) {
registry = undefined
}
}
function onExit () {
callRefs('exit')
}
function onBeforeExit () {
callRefs('beforeExit')
}
function callRefs (event) {
for (const ref of refs[event]) {
const obj = ref.deref()
const fn = ref.fn
// This should always happen, however GC is
// undeterministic so it might not happen.
/* istanbul ignore else */
if (obj !== undefined) {
fn(obj, event)
}
}
refs[event] = []
}
function clear (ref) {
for (const event of ['exit', 'beforeExit']) {
const index = refs[event].indexOf(ref)
refs[event].splice(index, index + 1)
uninstall(event)
}
}
function _register (event, obj, fn) {
if (obj === undefined) {
throw new Error('the object can\'t be undefined')
}
install(event)
const ref = new WeakRef(obj)
ref.fn = fn
ensureRegistry()
registry.register(obj, ref)
refs[event].push(ref)
}
function register (obj, fn) {
_register('exit', obj, fn)
}
function registerBeforeExit (obj, fn) {
_register('beforeExit', obj, fn)
}
function unregister (obj) {
if (registry === undefined) {
return
}
registry.unregister(obj)
for (const event of ['exit', 'beforeExit']) {
refs[event] = refs[event].filter((ref) => {
const _obj = ref.deref()
return _obj && _obj !== obj
})
uninstall(event)
}
}
module.exports = {
register,
registerBeforeExit,
unregister
}
+37
View File
@@ -0,0 +1,37 @@
{
"name": "on-exit-leak-free",
"version": "2.1.2",
"description": "Execute a function on exit without leaking memory, allowing all objects to be garbage collected",
"main": "index.js",
"scripts": {
"test": "standard | snazzy && tap test/*.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/mcollina/on-exit-or-gc.git"
},
"keywords": [
"weak",
"reference",
"finalization",
"registry",
"process",
"exit",
"garbage",
"collector"
],
"author": "Matteo Collina <hello@matteocollina.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/mcollina/on-exit-or-gc/issues"
},
"homepage": "https://github.com/mcollina/on-exit-or-gc#readme",
"devDependencies": {
"snazzy": "^9.0.0",
"standard": "^17.0.0",
"tap": "^16.0.0"
},
"engines": {
"node": ">=14.0.0"
}
}
+30
View File
@@ -0,0 +1,30 @@
'use strict'
const { test } = require('tap')
const { fork } = require('child_process')
const { join } = require('path')
const { once } = require('events')
const { register } = require('..')
const files = [
'close.js',
'beforeExit',
'gc-not-close.js',
'unregister.js'
]
for (const file of files) {
test(file, async ({ equal }) => {
const child = fork(join(__dirname, 'fixtures', file), [], {
execArgv: ['--expose-gc']
})
const [code] = await once(child, 'close')
equal(code, 0)
})
}
test('undefined', async ({ throws }) => {
throws(() => register(undefined))
})
+23
View File
@@ -0,0 +1,23 @@
'use strict'
const t = require('tap')
const { register, unregister } = require('..')
process.on('warning', () => {
t.fail('warning emitted')
})
const objs = []
for (let i = 0; i < 20; i++) {
const obj = { i }
objs.push(obj)
register(obj, shutdown)
}
for (const obj of objs) {
unregister(obj)
}
t.pass('completed')
function shutdown () {}
+33
View File
@@ -0,0 +1,33 @@
'use strict'
const { unregister, registerBeforeExit } = require('../..')
const assert = require('assert')
function setup () {
const obj = { foo: 'bar' }
registerBeforeExit(obj, shutdown)
}
let shutdownCalled = false
let timeoutFinished = false
function shutdown (obj, event) {
shutdownCalled = true
if (event === 'beforeExit') {
setTimeout(function () {
timeoutFinished = true
assert.strictEqual(obj.foo, 'bar')
unregister(obj)
}, 100)
process.on('beforeExit', function () {
assert.strictEqual(timeoutFinished, true)
})
} else {
throw new Error('different event')
}
}
setup()
process.on('exit', function () {
assert.strictEqual(shutdownCalled, true)
})
+21
View File
@@ -0,0 +1,21 @@
'use strict'
const { register } = require('../..')
const assert = require('assert')
function setup () {
const obj = { foo: 'bar' }
register(obj, shutdown)
}
let shutdownCalled = false
function shutdown (obj) {
shutdownCalled = true
assert.strictEqual(obj.foo, 'bar')
}
setup()
process.on('exit', function () {
assert.strictEqual(shutdownCalled, true)
})
+24
View File
@@ -0,0 +1,24 @@
'use strict'
const { register } = require('../..')
const assert = require('assert')
function setup () {
let obj = { foo: 'bar' }
register(obj, shutdown)
setImmediate(function () {
obj = undefined
gc() // eslint-disable-line
})
}
let shutdownCalled = false
function shutdown (obj) {
shutdownCalled = true
}
setup()
process.on('exit', function () {
assert.strictEqual(shutdownCalled, false)
})
+24
View File
@@ -0,0 +1,24 @@
'use strict'
const { register, unregister } = require('../..')
const assert = require('assert')
function setup () {
const obj = { foo: 'bar' }
register(obj, shutdown)
setImmediate(function () {
unregister(obj)
unregister(obj) // twice, this should not throw
})
}
let shutdownCalled = false
function shutdown (obj) {
shutdownCalled = true
}
setup()
process.on('exit', function () {
assert.strictEqual(shutdownCalled, false)
})
+13
View File
@@ -0,0 +1,13 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
open-pull-requests-limit: 10
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
+97
View File
@@ -0,0 +1,97 @@
name: CI
on:
push:
paths-ignore:
- 'docs/**'
- '*.md'
pull_request:
paths-ignore:
- 'docs/**'
- '*.md'
# This allows a subsequently queued workflow run to interrupt previous runs
concurrency:
group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}"
cancel-in-progress: true
jobs:
dependency-review:
name: Dependency Review
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out repo
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Dependency review
uses: actions/dependency-review-action@v3
test:
name: Test
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
matrix:
node-version: [18, 20, 22]
os: [macos-latest, ubuntu-latest, windows-latest]
steps:
- name: Check out repo
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Node ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Restore cached dependencies
uses: actions/cache@v4
with:
path: node_modules
key: node-modules-${{ hashFiles('package.json') }}
- name: Install dependencies
run: npm i --ignore-scripts
- name: Run Tests
run: npm run test-ci
- name: Coveralls Parallel
uses: coverallsapp/github-action@v2.1.2
with:
github-token: ${{ secrets.github_token }}
parallel: true
flag-name: run-${{ matrix.node-version }}-${{ matrix.os }}
coverage:
needs: test
runs-on: ubuntu-latest
steps:
- name: Coveralls Finished
uses: coverallsapp/github-action@v2.1.2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
parallel-finished: true
automerge:
name: Automerge Dependabot PRs
if: >
github.event_name == 'pull_request' &&
github.event.pull_request.user.login == 'dependabot[bot]'
needs: test
permissions:
pull-requests: write
contents: write
runs-on: ubuntu-latest
steps:
- uses: fastify/github-action-merge-dependabot@v3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm test
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 pino
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+172
View File
@@ -0,0 +1,172 @@
# pino-abstract-transport
[![npm version](https://img.shields.io/npm/v/pino-abstract-transport)](https://www.npmjs.com/package/pino-abstract-transport)
[![Build Status](https://img.shields.io/github/actions/workflow/status/pinojs/pino-abstract-transport/ci.yml?branch=main)](https://github.com/pinojs/pino-abstract-transport/actions)
[![Coverage Status](https://coveralls.io/repos/github/pinojs/pino-abstract-transport/badge.svg?branch=main)](https://coveralls.io/github/pinojs/pino-abstract-transport?branch=main)
[![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://standardjs.com/)
Write Pino transports easily.
## Install
```sh
npm i pino-abstract-transport
```
## Usage
```js
import build from 'pino-abstract-transport'
export default async function (opts) {
return build(async function (source) {
for await (let obj of source) {
console.log(obj)
}
})
}
```
or in CommonJS and streams:
```js
'use strict'
const build = require('pino-abstract-transport')
module.exports = function (opts) {
return build(function (source) {
source.on('data', function (obj) {
console.log(obj)
})
})
}
```
## Typescript usage
Install the type definitions for node. Make sure the major version of the type definitions matches the node version you are using.
#### Node 16
```sh
npm i -D @types/node@16
```
## API
### build(fn, opts) => Stream
Create a [`split2`](http://npm.im/split2) instance and returns it.
This same instance is also passed to the given function, which is called
synchronously.
If `opts.transform` is `true`, `pino-abstract-transform` will
wrap the split2 instance and the returned stream using [`duplexify`](https://www.npmjs.com/package/duplexify),
so they can be concatenated into multiple transports.
#### Events emitted
In addition to all events emitted by a [`Readable`](https://nodejs.org/api/stream.html#stream_class_stream_readable)
stream, it emits the following events:
* `unknown` where an unparsable line is found, both the line and optional error is emitted.
#### Options
* `parse` an option to change to data format passed to build function. When this option is set to `lines`,
the data is passed as a string, otherwise the data is passed as an object. Default: `undefined`.
* `close(err, cb)` a function that is called to shutdown the transport. It's called both on error and non-error shutdowns.
It can also return a promise. In this case discard the the `cb` argument.
* `parseLine(line)` a function that is used to parse line received from `pino`.
* `expectPinoConfig` a boolean that indicates if the transport expects Pino to add some of its configuration to the stream. Default: `false`.
## Example
### custom parseLine
You can allow custom `parseLine` from users while providing a simple and safe default parseLine.
```js
'use strict'
const build = require('pino-abstract-transport')
function defaultParseLine (line) {
const obj = JSON.parse(line)
// property foo will be added on each line
obj.foo = 'bar'
return obj
}
module.exports = function (opts) {
const parseLine = typeof opts.parseLine === 'function' ? opts.parseLine : defaultParseLine
return build(function (source) {
source.on('data', function (obj) {
console.log(obj)
})
}, {
parseLine: parseLine
})
}
```
### Stream concatenation / pipeline
You can pipeline multiple transports:
```js
const build = require('pino-abstract-transport')
const { Transform, pipeline } = require('stream')
function buildTransform () {
return build(function (source) {
return new Transform({
objectMode: true,
autoDestroy: true,
transform (line, enc, cb) {
line.service = 'bob'
cb(null, JSON.stringify(line))
}
})
}, { enablePipelining: true })
}
function buildDestination () {
return build(function (source) {
source.on('data', function (obj) {
console.log(obj)
})
})
}
pipeline(process.stdin, buildTransform(), buildDestination(), function (err) {
console.log('pipeline completed!', err)
})
```
### Using pino config
Setting `expectPinoConfig` to `true` will make the transport wait for pino to send its configuration before starting to process logs. It will add `levels`, `messageKey` and `errorKey` to the stream.
When used with an incompatible version of pino, the stream will immediately error.
```js
import build from 'pino-abstract-transport'
export default function (opts) {
return build(async function (source) {
for await (const obj of source) {
console.log(`[${source.levels.labels[obj.level]}]: ${obj[source.messageKey]}`)
}
}, {
expectPinoConfig: true
})
}
```
## License
MIT
+122
View File
@@ -0,0 +1,122 @@
// Type definitions for pino-abstract-transport 0.4.0
// Project: https://github.com/pinojs/pino-abstract-transport#readme
// Definitions by: Diyar Oktay <https://github.com/windupbird144>
/// <reference types="node" />
import { Transform } from "stream";
type BuildOptions = {
/**
* `parseLine(line)` a function that is used to parse line received from pino.
* @default JSON.parse
*/
parseLine?: (line: string) => unknown;
/**
* `parse` an option to change to data format passed to build function.
* @default undefined
*
*/
parse?: "lines";
/**
* `close(err, cb)` a function that is called to shutdown the transport.
* It's called both on error and non-error shutdowns. It can also return
* a promise. In this case discard the the cb argument.
*
* @example
* ```typescript
* {
* close: function (err, cb) {
* process.nextTick(cb, err)
* }
* }
* ```
* */
close?: (err: Error, cb: Function) => void | Promise<void>;
/**
* `metadata` If set to false, do not add metadata properties to the returned stream
*/
metadata?: false;
/**
* `expectPinoConfig` If set to true, the transport will wait for pino to send its
* configuration before starting to process logs.
*/
expectPinoConfig?: boolean;
};
/**
* Pass these options to wrap the split2 stream and
* the returned stream into a Duplex
*/
type EnablePipelining = BuildOptions & {
enablePipelining: true;
};
/**
* Create a split2 instance and returns it. This same instance is also passed
* to the given function, which is called after pino has sent its configuration.
*
* @returns {Promise<Transform>} the split2 instance
*/
declare function build(
fn: (transform: Transform & build.OnUnknown) => void | Promise<void>,
opts: BuildOptions & { expectPinoConfig: true }
): Promise<Transform & build.OnUnknown>;
/**
* Create a split2 instance and returns it. This same instance is also passed
* to the given function, which is called synchronously.
*
* @returns {Transform} the split2 instance
*/
declare function build(
fn: (transform: Transform & build.OnUnknown) => void | Promise<void>,
opts?: BuildOptions
): Transform & build.OnUnknown;
/**
* Creates a split2 instance and passes it to the given function, which is called
* after pino has sent its configuration. Then wraps the split2 instance and
* the returned stream into a Duplex, so they can be concatenated into multiple
* transports.
*
* @returns {Promise<Transform>} the wrapped split2 instance
*/
declare function build(
fn: (transform: Transform & build.OnUnknown) => Transform & build.OnUnknown,
opts: EnablePipelining & { expectPinoConfig: true }
): Promise<Transform>;
/**
* Creates a split2 instance and passes it to the given function, which is called
* synchronously. Then wraps the split2 instance and the returned stream into a
* Duplex, so they can be concatenated into multiple transports.
*
* @returns {Transform} the wrapped split2 instance
*/
declare function build(
fn: (transform: Transform & build.OnUnknown) => Transform & build.OnUnknown,
opts: EnablePipelining
): Transform;
declare namespace build {
export interface OnUnknown {
/**
* `unknown` is the event emitted where an unparsable line is found
*
* @param event 'unknown'
* @param line the unparsable line
* @param error the error that was thrown when parsing the line
*/
on(
event: "unknown",
listener: (line: string, error: unknown) => void
): void;
}
}
export = build;
+128
View File
@@ -0,0 +1,128 @@
'use strict'
const metadata = Symbol.for('pino.metadata')
const split = require('split2')
const { Duplex } = require('stream')
const { parentPort, workerData } = require('worker_threads')
function createDeferred () {
let resolve
let reject
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve
reject = _reject
})
promise.resolve = resolve
promise.reject = reject
return promise
}
module.exports = function build (fn, opts = {}) {
const waitForConfig = opts.expectPinoConfig === true && workerData?.workerData?.pinoWillSendConfig === true
const parseLines = opts.parse === 'lines'
const parseLine = typeof opts.parseLine === 'function' ? opts.parseLine : JSON.parse
const close = opts.close || defaultClose
const stream = split(function (line) {
let value
try {
value = parseLine(line)
} catch (error) {
this.emit('unknown', line, error)
return
}
if (value === null) {
this.emit('unknown', line, 'Null value ignored')
return
}
if (typeof value !== 'object') {
value = {
data: value,
time: Date.now()
}
}
if (stream[metadata]) {
stream.lastTime = value.time
stream.lastLevel = value.level
stream.lastObj = value
}
if (parseLines) {
return line
}
return value
}, { autoDestroy: true })
stream._destroy = function (err, cb) {
const promise = close(err, cb)
if (promise && typeof promise.then === 'function') {
promise.then(cb, cb)
}
}
if (opts.expectPinoConfig === true && workerData?.workerData?.pinoWillSendConfig !== true) {
setImmediate(() => {
stream.emit('error', new Error('This transport is not compatible with the current version of pino. Please upgrade pino to the latest version.'))
})
}
if (opts.metadata !== false) {
stream[metadata] = true
stream.lastTime = 0
stream.lastLevel = 0
stream.lastObj = null
}
if (waitForConfig) {
let pinoConfig = {}
const configReceived = createDeferred()
parentPort.on('message', function handleMessage (message) {
if (message.code === 'PINO_CONFIG') {
pinoConfig = message.config
configReceived.resolve()
parentPort.off('message', handleMessage)
}
})
Object.defineProperties(stream, {
levels: {
get () { return pinoConfig.levels }
},
messageKey: {
get () { return pinoConfig.messageKey }
},
errorKey: {
get () { return pinoConfig.errorKey }
}
})
return configReceived.then(finish)
}
return finish()
function finish () {
let res = fn(stream)
if (res && typeof res.catch === 'function') {
res.catch((err) => {
stream.destroy(err)
})
// set it to null to not retain a reference to the promise
res = null
} else if (opts.enablePipelining && res) {
return Duplex.from({ writable: stream, readable: res })
}
return stream
}
}
function defaultClose (err, cb) {
process.nextTick(cb, err)
}
+40
View File
@@ -0,0 +1,40 @@
{
"name": "pino-abstract-transport",
"version": "2.0.0",
"description": "Write Pino transports easily",
"main": "index.js",
"scripts": {
"prepare": "husky install",
"test": "standard | snazzy && tap test/*.test.js && tsd",
"test-ci": "standard | snazzy && tap test/*.test.js --coverage-report=lcovonly && tsd"
},
"repository": {
"type": "git",
"url": "git+https://github.com/pinojs/pino-abstract-transport.git"
},
"keywords": [
"pino",
"transport"
],
"author": "Matteo Collina <hello@matteocollina.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/pinojs/pino-abstract-transport/issues"
},
"homepage": "https://github.com/pinojs/pino-abstract-transport#readme",
"dependencies": {
"split2": "^4.0.0"
},
"devDependencies": {
"@types/node": "^20.1.0",
"husky": "^9.0.6",
"snazzy": "^9.0.0",
"standard": "^17.0.0",
"tap": "^16.0.0",
"thread-stream": "^2.6.0",
"tsd": "^0.31.0"
},
"tsd": {
"directory": "./test/types"
}
}
+445
View File
@@ -0,0 +1,445 @@
'use strict'
const { once } = require('events')
const { Transform, pipeline } = require('stream')
const { test } = require('tap')
const build = require('../')
test('parse newlined delimited JSON', ({ same, plan }) => {
plan(2)
const expected = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}]
const stream = build(function (source) {
source.on('data', function (line) {
same(expected.shift(), line)
})
})
const lines = expected.map(JSON.stringify).join('\n')
stream.write(lines)
stream.end()
})
test('parse newlined delimited JSON', ({ same, plan }) => {
plan(2)
const expected = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}]
const stream = build(function (source) {
source.on('data', function (line) {
same(expected.shift(), line)
})
}, { parse: 'json' })
const lines = expected.map(JSON.stringify).join('\n')
stream.write(lines)
stream.end()
})
test('null support', ({ same, plan }) => {
plan(1)
const stream = build(function (source) {
source.on('unknown', function (line) {
same('null', line)
})
})
stream.write('null\n')
stream.end()
})
test('broken json', ({ match, same, plan }) => {
plan(2)
const expected = '{ "truncated'
const stream = build(function (source) {
source.on('unknown', function (line, error) {
same(expected, line)
const regex = /^(Unexpected end of JSON input|Unterminated string in JSON at position 12)( \(line 1 column 13\))?$/
match(error.message, regex)
})
})
stream.write(expected + '\n')
stream.end()
})
test('pure values', ({ same, ok, plan }) => {
plan(3)
const stream = build(function (source) {
source.on('data', function (line) {
same(line.data, 42)
ok(line.time)
same(new Date(line.time).getTime(), line.time)
})
})
stream.write('42\n')
stream.end()
})
test('support async iteration', ({ same, plan }) => {
plan(2)
const expected = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}]
const stream = build(async function (source) {
for await (const line of source) {
same(expected.shift(), line)
}
})
const lines = expected.map(JSON.stringify).join('\n')
stream.write(lines)
stream.end()
})
test('rejecting errors the stream', async ({ same, plan }) => {
const stream = build(async function (source) {
throw new Error('kaboom')
})
const [err] = await once(stream, 'error')
same(err.message, 'kaboom')
})
test('emits an error if the transport expects pino to send the config, but pino is not going to', async function ({ plan, same }) {
plan(1)
const stream = build(() => {}, { expectPinoConfig: true })
const [err] = await once(stream, 'error')
same(err.message, 'This transport is not compatible with the current version of pino. Please upgrade pino to the latest version.')
})
test('set metadata', ({ same, plan, equal }) => {
plan(9)
const expected = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}]
const stream = build(function (source) {
source.on('data', function (line) {
const obj = expected.shift()
same(this.lastLevel, obj.level)
same(this.lastTime, obj.time)
same(this.lastObj, obj)
same(obj, line)
})
}, { metadata: true })
equal(stream[Symbol.for('pino.metadata')], true)
const lines = expected.map(JSON.stringify).join('\n')
stream.write(lines)
stream.end()
})
test('parse lines', ({ same, plan, equal }) => {
plan(9)
const expected = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}]
const stream = build(function (source) {
source.on('data', function (line) {
const obj = expected.shift()
same(this.lastLevel, obj.level)
same(this.lastTime, obj.time)
same(this.lastObj, obj)
same(JSON.stringify(obj), line)
})
}, { metadata: true, parse: 'lines' })
equal(stream[Symbol.for('pino.metadata')], true)
const lines = expected.map(JSON.stringify).join('\n')
stream.write(lines)
stream.end()
})
test('custom parse line function', ({ same, plan, equal }) => {
plan(11)
const expected = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}]
let num = 0
function parseLine (str) {
const obj = JSON.parse(str)
same(expected[num], obj)
return obj
}
const stream = build(function (source) {
source.on('data', function (line) {
const obj = expected[num]
same(this.lastLevel, obj.level)
same(this.lastTime, obj.time)
same(this.lastObj, obj)
same(obj, line)
num++
})
}, { metadata: true, parseLine })
equal(stream[Symbol.for('pino.metadata')], true)
const lines = expected.map(JSON.stringify).join('\n')
stream.write(lines)
stream.end()
})
test('set metadata (default)', ({ same, plan, equal }) => {
plan(9)
const expected = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}]
const stream = build(function (source) {
source.on('data', function (line) {
const obj = expected.shift()
same(this.lastLevel, obj.level)
same(this.lastTime, obj.time)
same(this.lastObj, obj)
same(obj, line)
})
})
equal(stream[Symbol.for('pino.metadata')], true)
const lines = expected.map(JSON.stringify).join('\n')
stream.write(lines)
stream.end()
})
test('do not set metadata', ({ same, plan, equal }) => {
plan(9)
const expected = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}]
const stream = build(function (source) {
source.on('data', function (line) {
const obj = expected.shift()
same(this.lastLevel, undefined)
same(this.lastTime, undefined)
same(this.lastObj, undefined)
same(obj, line)
})
}, { metadata: false })
equal(stream[Symbol.for('pino.metadata')], undefined)
const lines = expected.map(JSON.stringify).join('\n')
stream.write(lines)
stream.end()
})
test('close logic', ({ same, plan, pass }) => {
plan(3)
const expected = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}]
const stream = build(function (source) {
source.on('data', function (line) {
same(expected.shift(), line)
})
}, {
close (err, cb) {
pass('close called')
process.nextTick(cb, err)
}
})
const lines = expected.map(JSON.stringify).join('\n')
stream.write(lines)
stream.end()
})
test('close with promises', ({ same, plan, pass }) => {
plan(3)
const expected = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}]
const stream = build(function (source) {
source.on('data', function (line) {
same(expected.shift(), line)
})
}, {
async close () {
pass('close called')
}
})
const lines = expected.map(JSON.stringify).join('\n')
stream.write(lines)
stream.end()
})
test('support Transform streams', ({ same, plan, error }) => {
plan(7)
const expected1 = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}]
const expected2 = []
const stream1 = build(function (source) {
const transform = new Transform({
objectMode: true,
autoDestroy: true,
transform (chunk, enc, cb) {
same(expected1.shift(), chunk)
chunk.service = 'from transform'
expected2.push(chunk)
cb(null, JSON.stringify(chunk) + '\n')
}
})
pipeline(source, transform, () => {})
return transform
}, { enablePipelining: true })
const stream2 = build(function (source) {
source.on('data', function (line) {
same(expected2.shift(), line)
})
})
pipeline(stream1, stream2, function (err) {
error(err)
same(expected1, [])
same(expected2, [])
})
const lines = expected1.map(JSON.stringify).join('\n')
stream1.write(lines)
stream1.end()
})
@@ -0,0 +1,22 @@
'use strict'
const build = require('../..')
module.exports = async function (threadStreamOpts) {
const { port, opts = {} } = threadStreamOpts
return build(
async function (source) {
for await (const obj of source) {
port.postMessage({
data: obj,
pinoConfig: {
levels: source.levels,
messageKey: source.messageKey,
errorKey: source.errorKey
}
})
}
},
opts
)
}
@@ -0,0 +1,22 @@
'use strict'
const build = require('../..')
module.exports = async function (threadStreamOpts) {
const { port, opts = {} } = threadStreamOpts
return build(
function (source) {
source.on('data', function (line) {
port.postMessage({
data: line,
pinoConfig: {
levels: source.levels,
messageKey: source.messageKey,
errorKey: source.errorKey
}
})
})
},
opts
)
}
@@ -0,0 +1,24 @@
'use strict'
const { Transform, pipeline } = require('stream')
const build = require('../..')
module.exports = function (threadStreamOpts) {
const { opts = {} } = threadStreamOpts
return build(function (source) {
const transform = new Transform({
objectMode: true,
autoDestroy: true,
transform (chunk, enc, cb) {
chunk.service = 'from transform'
chunk.level = `${source.levels.labels[chunk.level]}(${chunk.level})`
chunk[source.messageKey] = chunk[source.messageKey].toUpperCase()
cb(null, JSON.stringify(chunk) + '\n')
}
})
pipeline(source, transform, () => {})
return transform
}, { ...opts, enablePipelining: true })
}
+15
View File
@@ -0,0 +1,15 @@
'use strict'
const { pipeline, PassThrough } = require('stream')
module.exports = async function ({ targets }) {
const streams = await Promise.all(targets.map(async (t) => {
const fn = require(t.target)
const stream = await fn(t.options)
return stream
}))
const stream = new PassThrough()
pipeline(stream, ...streams, () => {})
return stream
}
+31
View File
@@ -0,0 +1,31 @@
import build, { OnUnknown } from "../../index";
import { expectType } from "tsd";
import { Transform } from "stream";
/**
* If enablePipelining is set to true, the function passed as an argument
* must return a transform. The unknown event should be listened to on the
* stream passed in the first argument.
*/
expectType<Transform>(build((source) => source, { enablePipelining: true }));
/**
* If expectPinoConfig is set with enablePipelining, build returns a promise
*/
expectType<(Promise<Transform>)>(build((source) => source, { enablePipelining: true, expectPinoConfig: true }));
/**
* If enablePipelining is not set the unknown event can be listened to on
* the returned stream.
*/
expectType<Transform & OnUnknown>(build((source) => {}));
/**
* If expectPinoConfig is set, build returns a promise
*/
expectType<(Promise<Transform & OnUnknown>)>(build((source) => {}, { expectPinoConfig: true }));
/**
* build also accepts an async function
*/
expectType<Transform & OnUnknown>(build(async (source) => {}));
+357
View File
@@ -0,0 +1,357 @@
'use strict'
const { once } = require('events')
const { join } = require('path')
const ThreadStream = require('thread-stream')
const { MessageChannel } = require('worker_threads')
const { test } = require('tap')
workerTest('transport-on-data.js')
workerTest('transport-async-iteration.js', ' when using async iteration')
function workerTest (filename, description = '') {
test(`does not wait for pino to send config by default${description}`, function ({ same, plan }) {
plan(4)
const { port1, port2 } = new MessageChannel()
const stream = new ThreadStream({
filename: join(__dirname, 'fixtures', filename),
workerData: { port: port1 },
workerOpts: {
transferList: [port1]
}
})
const expected = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}]
const emptyPinoConfig = {
levels: undefined,
messageKey: undefined,
errorKey: undefined
}
port2.on('message', function (message) {
same(expected.shift(), message.data)
same(emptyPinoConfig, message.pinoConfig)
})
const lines = expected.map(JSON.stringify).join('\n')
stream.write(lines)
stream.end()
})
test(`does not wait for pino to send config if transport is not expecting it${description}`, function ({ same, plan }) {
plan(4)
const { port1, port2 } = new MessageChannel()
const stream = new ThreadStream({
filename: join(__dirname, 'fixtures', filename),
workerData: {
port: port1,
pinoWillSendConfig: true
},
workerOpts: {
transferList: [port1]
}
})
const expected = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}]
const emptyPinoConfig = {
levels: undefined,
messageKey: undefined,
errorKey: undefined
}
const pinoConfig = {
levels: {
labels: { 30: 'info' },
values: { info: 30 }
},
messageKey: 'msg',
errorKey: 'err'
}
stream.emit('message', { code: 'PINO_CONFIG', config: pinoConfig })
port2.on('message', function (message) {
same(expected.shift(), message.data)
same(emptyPinoConfig, message.pinoConfig)
})
const lines = expected.map(JSON.stringify).join('\n')
stream.write(lines)
stream.end()
})
test(`waits for the pino config when pino intends to send it and the transport requests it${description}`, function ({ same, plan }) {
plan(4)
const { port1, port2 } = new MessageChannel()
const stream = new ThreadStream({
filename: join(__dirname, 'fixtures', filename),
workerData: {
port: port1,
pinoWillSendConfig: true,
opts: {
expectPinoConfig: true
}
},
workerOpts: {
transferList: [port1]
}
})
const expected = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}]
const pinoConfig = {
levels: {
labels: { 30: 'info' },
values: { info: 30 }
},
messageKey: 'msg',
errorKey: 'err'
}
port2.on('message', function (message) {
same(expected.shift(), message.data)
same(pinoConfig, message.pinoConfig)
})
const lines = expected.map(JSON.stringify).join('\n')
stream.emit('message', { code: 'PINO_CONFIG', config: pinoConfig })
stream.write(lines)
stream.end()
})
test(`continues to listen if it receives a message that is not PINO_CONFIG${description}`, function ({ same, plan }) {
plan(4)
const { port1, port2 } = new MessageChannel()
const stream = new ThreadStream({
filename: join(__dirname, 'fixtures', 'transport-on-data.js'),
workerData: {
port: port1,
pinoWillSendConfig: true,
opts: {
expectPinoConfig: true
}
},
workerOpts: {
transferList: [port1]
}
})
const expected = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}]
const pinoConfig = {
levels: {
labels: { 30: 'info' },
values: { info: 30 }
},
messageKey: 'msg',
errorKey: 'err'
}
port2.on('message', function (message) {
same(expected.shift(), message.data)
same(pinoConfig, message.pinoConfig)
})
const lines = expected.map(JSON.stringify).join('\n')
stream.emit('message', 'not a PINO_CONFIG')
stream.emit('message', { code: 'NOT_PINO_CONFIG', config: { levels: 'foo', messageKey: 'bar', errorKey: 'baz' } })
stream.emit('message', { code: 'PINO_CONFIG', config: pinoConfig })
stream.write(lines)
stream.end()
})
test(`waits for the pino config even if it is sent after write${description}`, function ({ same, plan }) {
plan(4)
const { port1, port2 } = new MessageChannel()
const stream = new ThreadStream({
filename: join(__dirname, 'fixtures', filename),
workerData: {
port: port1,
pinoWillSendConfig: true,
opts: {
expectPinoConfig: true
}
},
workerOpts: {
transferList: [port1]
}
})
const expected = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}]
const pinoConfig = {
levels: {
labels: { 30: 'info' },
values: { info: 30 }
},
messageKey: 'msg',
errorKey: 'err'
}
port2.on('message', function (message) {
same(expected.shift(), message.data)
same(pinoConfig, message.pinoConfig)
})
const lines = expected.map(JSON.stringify).join('\n')
stream.write(lines)
stream.emit('message', { code: 'PINO_CONFIG', config: pinoConfig })
stream.end()
})
test(`emits an error if the transport expects pino to send the config, but pino is not going to${description}`, async function ({ plan, same, ok }) {
plan(2)
const stream = new ThreadStream({
filename: join(__dirname, 'fixtures', filename),
workerData: {
opts: {
expectPinoConfig: true
}
}
})
const [err] = await once(stream, 'error')
same(err.message, 'This transport is not compatible with the current version of pino. Please upgrade pino to the latest version.')
ok(stream.destroyed)
})
}
test('waits for the pino config when pipelining', function ({ same, plan }) {
plan(2)
const { port1, port2 } = new MessageChannel()
const stream = new ThreadStream({
filename: join(__dirname, 'fixtures', 'worker-pipeline.js'),
workerData: {
pinoWillSendConfig: true,
targets: [{
target: './transport-transform.js',
options: {
opts: { expectPinoConfig: true }
}
}, {
target: './transport-on-data.js',
options: {
port: port1
}
}]
},
workerOpts: {
transferList: [port1]
}
})
const expected = [{
level: 'info(30)',
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'HELLO WORLD',
service: 'from transform'
}, {
level: 'info(30)',
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'ANOTHER MESSAGE',
prop: 42,
service: 'from transform'
}]
const lines = [{
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'hello world'
}, {
level: 30,
time: 1617955768092,
pid: 2942,
hostname: 'MacBook-Pro.local',
msg: 'another message',
prop: 42
}].map(JSON.stringify).join('\n')
const pinoConfig = {
levels: {
labels: { 30: 'info' },
values: { info: 30 }
},
messageKey: 'msg',
errorKey: 'err'
}
port2.on('message', function (message) {
same(expected.shift(), message.data)
})
stream.emit('message', { code: 'PINO_CONFIG', config: pinoConfig })
stream.write(lines)
stream.end()
})
+13
View File
@@ -0,0 +1,13 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
# [*.md]
# trim_trailing_whitespace = false
+2
View File
@@ -0,0 +1,2 @@
index.d.ts
test/types/index.test-d.ts
+13
View File
@@ -0,0 +1,13 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
open-pull-requests-limit: 10
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
+81
View File
@@ -0,0 +1,81 @@
name: CI
on:
push:
paths-ignore:
- 'docs/**'
- '*.md'
pull_request:
paths-ignore:
- 'docs/**'
- '*.md'
# This allows a subsequently queued workflow run to interrupt previous runs
concurrency:
group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}"
cancel-in-progress: true
jobs:
dependency-review:
name: Dependency Review
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out repo
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Dependency review
uses: actions/dependency-review-action@v4
test:
name: Test
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
node-version: [18, 20]
steps:
- name: Check out repo
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Node ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm install --ignore-scripts
env:
NODE_ENV: development
- name: Lint-CI
run: npm run lint-ci
- name: Test-Types
run: npm run test-types
- name: Test-CI
run: npm run test-ci
automerge:
name: Automerge Dependabot PRs
if: >
github.event_name == 'pull_request' &&
github.event.pull_request.user.login == 'dependabot[bot]'
needs: test
permissions:
pull-requests: write
contents: write
runs-on: ubuntu-latest
steps:
- uses: fastify/github-action-merge-dependabot@v3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+7
View File
@@ -0,0 +1,7 @@
Copyright Mateo Collina, David Mark Clements, James Sumners
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+182
View File
@@ -0,0 +1,182 @@
# pino-std-serializers&nbsp;&nbsp;[![CI](https://github.com/pinojs/pino-std-serializers/workflows/CI/badge.svg)](https://github.com/pinojs/pino-std-serializers/actions?query=workflow%3ACI)
This module provides a set of standard object serializers for the
[Pino](https://getpino.io) logger.
## Serializers
### `exports.err(error)`
Serializes an `Error` like object. Returns an object:
```js
{
type: 'string', // The name of the object's constructor.
message: 'string', // The supplied error message.
stack: 'string', // The stack when the error was generated.
raw: Error // Non-enumerable, i.e. will not be in the output, original
// Error object. This is available for subsequent serializers
// to use.
[...any additional Enumerable property the original Error had]
}
```
Any other extra properties, e.g. `statusCode`, that have been attached to the
object will also be present on the serialized object.
If the error object has a [`cause`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) property, the `cause`'s `message` and `stack` will be appended to the top-level `message` and `stack`. All other parameters that belong to the `error.cause` object will be omitted.
Example:
```js
const serializer = require('pino-std-serializers').err;
const innerError = new Error("inner error");
innerError.isInner = true;
const outerError = new Error("outer error", { cause: innerError });
outerError.isInner = false;
const serialized = serializer(outerError);
/* Result:
{
"type": "Error",
"message": "outer error: inner error",
"isInner": false,
"stack": "Error: outer error
at <...omitted..>
caused by: Error: inner error
at <...omitted..>
}
*/
```
### `exports.errWithCause(error)`
Serializes an `Error` like object, including any `error.cause`. Returns an object:
```js
{
type: 'string', // The name of the object's constructor.
message: 'string', // The supplied error message.
stack: 'string', // The stack when the error was generated.
cause?: Error, // If the original error had an error.cause, it will be serialized here
raw: Error // Non-enumerable, i.e. will not be in the output, original
// Error object. This is available for subsequent serializers
// to use.
[...any additional Enumerable property the original Error had]
}
```
Any other extra properties, e.g. `statusCode`, that have been attached to the object will also be present on the serialized object.
Example:
```javascript
const serializer = require('pino-std-serializers').errWithCause;
const innerError = new Error("inner error");
innerError.isInner = true;
const outerError = new Error("outer error", { cause: innerError });
outerError.isInner = false;
const serialized = serializer(outerError);
/* Result:
{
"type": "Error",
"message": "outer error",
"isInner": false,
"stack": "Error: outer error
at <...omitted..>",
"cause": {
"type": "Error",
"message": "inner error",
"isInner": true,
"stack": "Error: inner error
at <...omitted..>"
},
}
*/
```
### `exports.mapHttpResponse(response)`
Used internally by Pino for general response logging. Returns an object:
```js
{
res: {}
}
```
Where `res` is the `response` as serialized by the standard response serializer.
### `exports.mapHttpRequest(request)`
Used internall by Pino for general request logging. Returns an object:
```js
{
req: {}
}
```
Where `req` is the `request` as serialized by the standard request serializer.
### `exports.req(request)`
The default `request` serializer. Returns an object:
```js
{
id: 'string', // Defaults to `undefined`, unless there is an `id` property
// already attached to the `request` object or to the `request.info`
// object. Attach a synchronous function
// to the `request.id` that returns an identifier to have
// the value filled.
method: 'string',
url: 'string', // the request pathname (as per req.url in core HTTP)
query: 'object', // the request query (as per req.query in express or hapi)
params: 'object', // the request params (as per req.params in express or hapi)
headers: Object, // a reference to the `headers` object from the request
// (as per req.headers in core HTTP)
remoteAddress: 'string',
remotePort: Number,
raw: Object // Non-enumerable, i.e. will not be in the output, original
// request object. This is available for subsequent serializers
// to use. In cases where the `request` input already has
// a `raw` property this will replace the original `request.raw`
// property
}
```
### `exports.res(response)`
The default `response` serializer. Returns an object:
```js
{
statusCode: Number, // Response status code, will be null before headers are flushed
headers: Object, // The headers to be sent in the response.
raw: Object // Non-enumerable, i.e. will not be in the output, original
// response object. This is available for subsequent serializers
// to use.
}
```
### `exports.wrapErrorSerializer(customSerializer)`
A utility method for wrapping the default error serializer. This allows
custom serializers to work with the already serialized object.
The `customSerializer` accepts one parameter — the newly serialized error
object — and returns the new (or updated) error object.
### `exports.wrapRequestSerializer(customSerializer)`
A utility method for wrapping the default request serializer. This allows
custom serializers to work with the already serialized object.
The `customSerializer` accepts one parameter — the newly serialized request
object — and returns the new (or updated) request object.
### `exports.wrapResponseSerializer(customSerializer)`
A utility method for wrapping the default response serializer. This allows
custom serializers to work with the already serialized object.
The `customSerializer` accepts one parameter — the newly serialized response
object — and returns the new (or updated) response object.
## License
MIT License
+145
View File
@@ -0,0 +1,145 @@
// Type definitions for pino-std-serializers 2.4
// Definitions by: Connor Fitzgerald <https://github.com/connorjayfitzgerald>
// Igor Savin <https://github.com/kibertoad>
// TypeScript Version: 2.7
/// <reference types="node" />
import { IncomingMessage, ServerResponse } from 'http';
export interface SerializedError {
/**
* The name of the object's constructor.
*/
type: string;
/**
* The supplied error message.
*/
message: string;
/**
* The stack when the error was generated.
*/
stack: string;
/**
* Non-enumerable. The original Error object. This will not be included in the logged output.
* This is available for subsequent serializers to use.
*/
raw: Error;
/**
* `cause` is never included in the log output, if you need the `cause`, use {@link raw.cause}
*/
cause?: never;
/**
* Any other extra properties that have been attached to the object will also be present on the serialized object.
*/
[key: string]: any;
[key: number]: any;
}
/**
* Serializes an Error object. Does not serialize "err.cause" fields (will append the err.cause.message to err.message
* and err.cause.stack to err.stack)
*/
export function err(err: Error): SerializedError;
/**
* Serializes an Error object, including full serialization for any err.cause fields recursively.
*/
export function errWithCause(err: Error): SerializedError;
export interface SerializedRequest {
/**
* Defaults to `undefined`, unless there is an `id` property already attached to the `request` object or
* to the `request.info` object. Attach a synchronous function to the `request.id` that returns an
* identifier to have the value filled.
*/
id: string | undefined;
/**
* HTTP method.
*/
method: string;
/**
* Request pathname (as per req.url in core HTTP).
*/
url: string;
/**
* Reference to the `headers` object from the request (as per req.headers in core HTTP).
*/
headers: Record<string, string>;
remoteAddress: string;
remotePort: number;
params: Record<string, string>;
query: Record<string, string>;
/**
* Non-enumerable, i.e. will not be in the output, original request object. This is available for subsequent
* serializers to use. In cases where the `request` input already has a `raw` property this will
* replace the original `request.raw` property.
*/
raw: IncomingMessage;
}
/**
* Serializes a Request object.
*/
export function req(req: IncomingMessage): SerializedRequest;
/**
* Used internally by Pino for general request logging.
*/
export function mapHttpRequest(req: IncomingMessage): {
req: SerializedRequest
};
export interface SerializedResponse {
/**
* HTTP status code.
*/
statusCode: number;
/**
* The headers to be sent in the response.
*/
headers: Record<string, string>;
/**
* Non-enumerable, i.e. will not be in the output, original response object. This is available for subsequent serializers to use.
*/
raw: ServerResponse;
}
/**
* Serializes a Response object.
*/
export function res(res: ServerResponse): SerializedResponse;
/**
* Used internally by Pino for general response logging.
*/
export function mapHttpResponse(res: ServerResponse): {
res: SerializedResponse
};
export type CustomErrorSerializer = (err: SerializedError) => Record<string, any>;
/**
* A utility method for wrapping the default error serializer.
* This allows custom serializers to work with the already serialized object.
* The customSerializer accepts one parameter — the newly serialized error object — and returns the new (or updated) error object.
*/
export function wrapErrorSerializer(customSerializer: CustomErrorSerializer): (err: Error) => Record<string, any>;
export type CustomRequestSerializer = (req: SerializedRequest) => Record<string, any>;
/**
* A utility method for wrapping the default request serializer.
* This allows custom serializers to work with the already serialized object.
* The customSerializer accepts one parameter — the newly serialized request object — and returns the new (or updated) request object.
*/
export function wrapRequestSerializer(customSerializer: CustomRequestSerializer): (req: IncomingMessage) => Record<string, any>;
export type CustomResponseSerializer = (res: SerializedResponse) => Record<string, any>;
/**
* A utility method for wrapping the default response serializer.
* This allows custom serializers to work with the already serialized object.
* The customSerializer accepts one parameter — the newly serialized response object — and returns the new (or updated) response object.
*/
export function wrapResponseSerializer(customSerializer: CustomResponseSerializer): (res: ServerResponse) => Record<string, any>;
+36
View File
@@ -0,0 +1,36 @@
'use strict'
const errSerializer = require('./lib/err')
const errWithCauseSerializer = require('./lib/err-with-cause')
const reqSerializers = require('./lib/req')
const resSerializers = require('./lib/res')
module.exports = {
err: errSerializer,
errWithCause: errWithCauseSerializer,
mapHttpRequest: reqSerializers.mapHttpRequest,
mapHttpResponse: resSerializers.mapHttpResponse,
req: reqSerializers.reqSerializer,
res: resSerializers.resSerializer,
wrapErrorSerializer: function wrapErrorSerializer (customSerializer) {
if (customSerializer === errSerializer) return customSerializer
return function wrapErrSerializer (err) {
return customSerializer(errSerializer(err))
}
},
wrapRequestSerializer: function wrapRequestSerializer (customSerializer) {
if (customSerializer === reqSerializers.reqSerializer) return customSerializer
return function wrappedReqSerializer (req) {
return customSerializer(reqSerializers.reqSerializer(req))
}
},
wrapResponseSerializer: function wrapResponseSerializer (customSerializer) {
if (customSerializer === resSerializers.resSerializer) return customSerializer
return function wrappedResSerializer (res) {
return customSerializer(resSerializers.resSerializer(res))
}
}
}
+118
View File
@@ -0,0 +1,118 @@
'use strict'
// **************************************************************
// * Code initially copied/adapted from "pony-cause" npm module *
// * Please upstream improvements there *
// **************************************************************
const isErrorLike = (err) => {
return err && typeof err.message === 'string'
}
/**
* @param {Error|{ cause?: unknown|(()=>err)}} err
* @returns {Error|Object|undefined}
*/
const getErrorCause = (err) => {
if (!err) return
/** @type {unknown} */
// @ts-ignore
const cause = err.cause
// VError / NError style causes
if (typeof cause === 'function') {
// @ts-ignore
const causeResult = err.cause()
return isErrorLike(causeResult)
? causeResult
: undefined
} else {
return isErrorLike(cause)
? cause
: undefined
}
}
/**
* Internal method that keeps a track of which error we have already added, to avoid circular recursion
*
* @private
* @param {Error} err
* @param {Set<Error>} seen
* @returns {string}
*/
const _stackWithCauses = (err, seen) => {
if (!isErrorLike(err)) return ''
const stack = err.stack || ''
// Ensure we don't go circular or crazily deep
if (seen.has(err)) {
return stack + '\ncauses have become circular...'
}
const cause = getErrorCause(err)
if (cause) {
seen.add(err)
return (stack + '\ncaused by: ' + _stackWithCauses(cause, seen))
} else {
return stack
}
}
/**
* @param {Error} err
* @returns {string}
*/
const stackWithCauses = (err) => _stackWithCauses(err, new Set())
/**
* Internal method that keeps a track of which error we have already added, to avoid circular recursion
*
* @private
* @param {Error} err
* @param {Set<Error>} seen
* @param {boolean} [skip]
* @returns {string}
*/
const _messageWithCauses = (err, seen, skip) => {
if (!isErrorLike(err)) return ''
const message = skip ? '' : (err.message || '')
// Ensure we don't go circular or crazily deep
if (seen.has(err)) {
return message + ': ...'
}
const cause = getErrorCause(err)
if (cause) {
seen.add(err)
// @ts-ignore
const skipIfVErrorStyleCause = typeof err.cause === 'function'
return (message +
(skipIfVErrorStyleCause ? '' : ': ') +
_messageWithCauses(cause, seen, skipIfVErrorStyleCause))
} else {
return message
}
}
/**
* @param {Error} err
* @returns {string}
*/
const messageWithCauses = (err) => _messageWithCauses(err, new Set())
module.exports = {
isErrorLike,
getErrorCause,
stackWithCauses,
messageWithCauses
}
+48
View File
@@ -0,0 +1,48 @@
'use strict'
const seen = Symbol('circular-ref-tag')
const rawSymbol = Symbol('pino-raw-err-ref')
const pinoErrProto = Object.create({}, {
type: {
enumerable: true,
writable: true,
value: undefined
},
message: {
enumerable: true,
writable: true,
value: undefined
},
stack: {
enumerable: true,
writable: true,
value: undefined
},
aggregateErrors: {
enumerable: true,
writable: true,
value: undefined
},
raw: {
enumerable: false,
get: function () {
return this[rawSymbol]
},
set: function (val) {
this[rawSymbol] = val
}
}
})
Object.defineProperty(pinoErrProto, rawSymbol, {
writable: true,
value: {}
})
module.exports = {
pinoErrProto,
pinoErrorSymbols: {
seen,
rawSymbol
}
}
+48
View File
@@ -0,0 +1,48 @@
'use strict'
module.exports = errWithCauseSerializer
const { isErrorLike } = require('./err-helpers')
const { pinoErrProto, pinoErrorSymbols } = require('./err-proto')
const { seen } = pinoErrorSymbols
const { toString } = Object.prototype
function errWithCauseSerializer (err) {
if (!isErrorLike(err)) {
return err
}
err[seen] = undefined // tag to prevent re-looking at this
const _err = Object.create(pinoErrProto)
_err.type = toString.call(err.constructor) === '[object Function]'
? err.constructor.name
: err.name
_err.message = err.message
_err.stack = err.stack
if (Array.isArray(err.errors)) {
_err.aggregateErrors = err.errors.map(err => errWithCauseSerializer(err))
}
if (isErrorLike(err.cause) && !Object.prototype.hasOwnProperty.call(err.cause, seen)) {
_err.cause = errWithCauseSerializer(err.cause)
}
for (const key in err) {
if (_err[key] === undefined) {
const val = err[key]
if (isErrorLike(val)) {
if (!Object.prototype.hasOwnProperty.call(val, seen)) {
_err[key] = errWithCauseSerializer(val)
}
} else {
_err[key] = val
}
}
}
delete err[seen] // clean up tag in case err is serialized again later
_err.raw = err
return _err
}
+45
View File
@@ -0,0 +1,45 @@
'use strict'
module.exports = errSerializer
const { messageWithCauses, stackWithCauses, isErrorLike } = require('./err-helpers')
const { pinoErrProto, pinoErrorSymbols } = require('./err-proto')
const { seen } = pinoErrorSymbols
const { toString } = Object.prototype
function errSerializer (err) {
if (!isErrorLike(err)) {
return err
}
err[seen] = undefined // tag to prevent re-looking at this
const _err = Object.create(pinoErrProto)
_err.type = toString.call(err.constructor) === '[object Function]'
? err.constructor.name
: err.name
_err.message = messageWithCauses(err)
_err.stack = stackWithCauses(err)
if (Array.isArray(err.errors)) {
_err.aggregateErrors = err.errors.map(err => errSerializer(err))
}
for (const key in err) {
if (_err[key] === undefined) {
const val = err[key]
if (isErrorLike(val)) {
// We append cause messages and stacks to _err, therefore skipping causes here
if (key !== 'cause' && !Object.prototype.hasOwnProperty.call(val, seen)) {
_err[key] = errSerializer(val)
}
} else {
_err[key] = val
}
}
}
delete err[seen] // clean up tag in case err is serialized again later
_err.raw = err
return _err
}
+100
View File
@@ -0,0 +1,100 @@
'use strict'
module.exports = {
mapHttpRequest,
reqSerializer
}
const rawSymbol = Symbol('pino-raw-req-ref')
const pinoReqProto = Object.create({}, {
id: {
enumerable: true,
writable: true,
value: ''
},
method: {
enumerable: true,
writable: true,
value: ''
},
url: {
enumerable: true,
writable: true,
value: ''
},
query: {
enumerable: true,
writable: true,
value: ''
},
params: {
enumerable: true,
writable: true,
value: ''
},
headers: {
enumerable: true,
writable: true,
value: {}
},
remoteAddress: {
enumerable: true,
writable: true,
value: ''
},
remotePort: {
enumerable: true,
writable: true,
value: ''
},
raw: {
enumerable: false,
get: function () {
return this[rawSymbol]
},
set: function (val) {
this[rawSymbol] = val
}
}
})
Object.defineProperty(pinoReqProto, rawSymbol, {
writable: true,
value: {}
})
function reqSerializer (req) {
// req.info is for hapi compat.
const connection = req.info || req.socket
const _req = Object.create(pinoReqProto)
_req.id = (typeof req.id === 'function' ? req.id() : (req.id || (req.info ? req.info.id : undefined)))
_req.method = req.method
// req.originalUrl is for expressjs compat.
if (req.originalUrl) {
_req.url = req.originalUrl
} else {
const path = req.path
// path for safe hapi compat.
_req.url = typeof path === 'string' ? path : (req.url ? req.url.path || req.url : undefined)
}
if (req.query) {
_req.query = req.query
}
if (req.params) {
_req.params = req.params
}
_req.headers = req.headers
_req.remoteAddress = connection && connection.remoteAddress
_req.remotePort = connection && connection.remotePort
// req.raw is for hapi compat/equivalence
_req.raw = req.raw || req
return _req
}
function mapHttpRequest (req) {
return {
req: reqSerializer(req)
}
}
+47
View File
@@ -0,0 +1,47 @@
'use strict'
module.exports = {
mapHttpResponse,
resSerializer
}
const rawSymbol = Symbol('pino-raw-res-ref')
const pinoResProto = Object.create({}, {
statusCode: {
enumerable: true,
writable: true,
value: 0
},
headers: {
enumerable: true,
writable: true,
value: ''
},
raw: {
enumerable: false,
get: function () {
return this[rawSymbol]
},
set: function (val) {
this[rawSymbol] = val
}
}
})
Object.defineProperty(pinoResProto, rawSymbol, {
writable: true,
value: {}
})
function resSerializer (res) {
const _res = Object.create(pinoResProto)
_res.statusCode = res.headersSent ? res.statusCode : null
_res.headers = res.getHeaders ? res.getHeaders() : res._headers
_res.raw = res
return _res
}
function mapHttpResponse (res) {
return {
res: resSerializer(res)
}
}
+47
View File
@@ -0,0 +1,47 @@
{
"name": "pino-std-serializers",
"version": "7.0.0",
"description": "A collection of standard object serializers for Pino",
"main": "index.js",
"type": "commonjs",
"types": "index.d.ts",
"scripts": {
"lint": "standard | snazzy",
"lint-ci": "standard",
"test": "borp -p 'test/**/*.js'",
"test-ci": "borp --coverage -p 'test/**/*.js'",
"test-types": "tsc && tsd"
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/pinojs/pino-std-serializers.git"
},
"keywords": [
"pino",
"logging"
],
"author": "James Sumners <james.sumners@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/pinojs/pino-std-serializers/issues"
},
"homepage": "https://github.com/pinojs/pino-std-serializers#readme",
"precommit": [
"lint",
"test",
"test-types"
],
"devDependencies": {
"@matteo.collina/tspl": "^0.1.1",
"@types/node": "^20.11.17",
"borp": "^0.9.1",
"pre-commit": "^1.2.2",
"snazzy": "^9.0.0",
"standard": "^17.1.0",
"tsd": "^0.31.0",
"typescript": "^5.3.3"
},
"tsd": {
"directory": "test/types"
}
}
+187
View File
@@ -0,0 +1,187 @@
'use strict'
const { test } = require('node:test')
const assert = require('node:assert')
const serializer = require('../lib/err-with-cause')
const { wrapErrorSerializer } = require('../')
test('serializes Error objects', () => {
const serialized = serializer(Error('foo'))
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'foo')
assert.match(serialized.stack, /err-with-cause\.test\.js:/)
})
test('serializes Error objects with extra properties', () => {
const err = Error('foo')
err.statusCode = 500
const serialized = serializer(err)
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'foo')
assert.ok(serialized.statusCode)
assert.strictEqual(serialized.statusCode, 500)
assert.match(serialized.stack, /err-with-cause\.test\.js:/)
})
test('serializes Error objects with subclass "type"', () => {
class MyError extends Error {}
const err = new MyError('foo')
const serialized = serializer(err)
assert.strictEqual(serialized.type, 'MyError')
})
test('serializes nested errors', () => {
const err = Error('foo')
err.inner = Error('bar')
const serialized = serializer(err)
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'foo')
assert.match(serialized.stack, /err-with-cause\.test\.js:/)
assert.strictEqual(serialized.inner.type, 'Error')
assert.strictEqual(serialized.inner.message, 'bar')
assert.match(serialized.inner.stack, /Error: bar/)
assert.match(serialized.inner.stack, /err-with-cause\.test\.js:/)
})
test('serializes error causes', () => {
const innerErr = Error('inner')
const middleErr = Error('middle')
middleErr.cause = innerErr
const outerErr = Error('outer')
outerErr.cause = middleErr
const serialized = serializer(outerErr)
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'outer')
assert.match(serialized.stack, /err-with-cause\.test\.js:/)
assert.strictEqual(serialized.cause.type, 'Error')
assert.strictEqual(serialized.cause.message, 'middle')
assert.match(serialized.cause.stack, /err-with-cause\.test\.js:/)
assert.strictEqual(serialized.cause.cause.type, 'Error')
assert.strictEqual(serialized.cause.cause.message, 'inner')
assert.match(serialized.cause.cause.stack, /err-with-cause\.test\.js:/)
})
test('keeps non-error cause', () => {
const err = Error('foo')
err.cause = 'abc'
const serialized = serializer(err)
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'foo')
assert.strictEqual(serialized.cause, 'abc')
})
test('prevents infinite recursion', () => {
const err = Error('foo')
err.inner = err
const serialized = serializer(err)
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'foo')
assert.match(serialized.stack, /err-with-cause\.test\.js:/)
assert.ok(!serialized.inner)
})
test('cleans up infinite recursion tracking', () => {
const err = Error('foo')
const bar = Error('bar')
err.inner = bar
bar.inner = err
serializer(err)
const serialized = serializer(err)
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'foo')
assert.match(serialized.stack, /err-with-cause\.test\.js:/)
assert.ok(serialized.inner)
assert.strictEqual(serialized.inner.type, 'Error')
assert.strictEqual(serialized.inner.message, 'bar')
assert.match(serialized.inner.stack, /Error: bar/)
assert.ok(!serialized.inner.inner)
})
test('err.raw is available', () => {
const err = Error('foo')
const serialized = serializer(err)
assert.strictEqual(serialized.raw, err)
})
test('redefined err.constructor doesnt crash serializer', () => {
function check (a, name) {
assert.strictEqual(a.type, name)
assert.strictEqual(a.message, 'foo')
}
const err1 = TypeError('foo')
err1.constructor = '10'
const err2 = TypeError('foo')
err2.constructor = undefined
const err3 = Error('foo')
err3.constructor = null
const err4 = Error('foo')
err4.constructor = 10
class MyError extends Error {}
const err5 = new MyError('foo')
err5.constructor = undefined
check(serializer(err1), 'TypeError')
check(serializer(err2), 'TypeError')
check(serializer(err3), 'Error')
check(serializer(err4), 'Error')
// We do not expect 'MyError' because err5.constructor has been blown away.
// `err5.name` is 'Error' from the base class prototype.
check(serializer(err5), 'Error')
})
test('pass through anything that does not look like an Error', () => {
function check (a) {
assert.strictEqual(serializer(a), a)
}
check('foo')
check({ hello: 'world' })
check([1, 2])
})
test('can wrap err serializers', () => {
const err = Error('foo')
err.foo = 'foo'
const serializer = wrapErrorSerializer(function (err) {
delete err.foo
err.bar = 'bar'
return err
})
const serialized = serializer(err)
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'foo')
assert.match(serialized.stack, /err-with-cause\.test\.js:/)
assert.ok(!serialized.foo)
assert.strictEqual(serialized.bar, 'bar')
})
test('serializes aggregate errors', { skip: !global.AggregateError }, () => {
const foo = new Error('foo')
const bar = new Error('bar')
for (const aggregate of [
new AggregateError([foo, bar], 'aggregated message'), // eslint-disable-line no-undef
{ errors: [foo, bar], message: 'aggregated message', stack: 'err-with-cause.test.js:' }
]) {
const serialized = serializer(aggregate)
assert.strictEqual(serialized.message, 'aggregated message')
assert.strictEqual(serialized.aggregateErrors.length, 2)
assert.strictEqual(serialized.aggregateErrors[0].message, 'foo')
assert.strictEqual(serialized.aggregateErrors[1].message, 'bar')
assert.match(serialized.aggregateErrors[0].stack, /^Error: foo/)
assert.match(serialized.aggregateErrors[1].stack, /^Error: bar/)
assert.match(serialized.stack, /err-with-cause\.test\.js:/)
}
})
+200
View File
@@ -0,0 +1,200 @@
'use strict'
const assert = require('node:assert')
const { test } = require('node:test')
const serializer = require('../lib/err')
const { wrapErrorSerializer } = require('../')
test('serializes Error objects', () => {
const serialized = serializer(Error('foo'))
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'foo')
assert.match(serialized.stack, /err\.test\.js:/)
})
test('serializes Error objects with extra properties', () => {
const err = Error('foo')
err.statusCode = 500
const serialized = serializer(err)
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'foo')
assert.ok(serialized.statusCode)
assert.strictEqual(serialized.statusCode, 500)
assert.match(serialized.stack, /err\.test\.js:/)
})
test('serializes Error objects with subclass "type"', () => {
class MyError extends Error {}
const err = new MyError('foo')
const serialized = serializer(err)
assert.strictEqual(serialized.type, 'MyError')
})
test('serializes nested errors', () => {
const err = Error('foo')
err.inner = Error('bar')
const serialized = serializer(err)
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'foo')
assert.match(serialized.stack, /err\.test\.js:/)
assert.strictEqual(serialized.inner.type, 'Error')
assert.strictEqual(serialized.inner.message, 'bar')
assert.match(serialized.inner.stack, /Error: bar/)
assert.match(serialized.inner.stack, /err\.test\.js:/)
})
test('serializes error causes', () => {
for (const cause of [
Error('bar'),
{ message: 'bar', stack: 'Error: bar: err.test.js:' }
]) {
const err = Error('foo')
err.cause = cause
err.cause.cause = Error('abc')
const serialized = serializer(err)
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'foo: bar: abc')
assert.match(serialized.stack, /err\.test\.js:/)
assert.match(serialized.stack, /Error: foo/)
assert.match(serialized.stack, /Error: bar/)
assert.match(serialized.stack, /Error: abc/)
assert.ok(!serialized.cause)
}
})
test('serializes error causes with VError support', function (t) {
// Fake VError-style setup
const err = Error('foo: bar')
err.foo = 'abc'
err.cause = function () {
const err = Error('bar')
err.cause = Error(this.foo)
return err
}
const serialized = serializer(err)
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'foo: bar: abc')
assert.match(serialized.stack, /err\.test\.js:/)
assert.match(serialized.stack, /Error: foo/)
assert.match(serialized.stack, /Error: bar/)
assert.match(serialized.stack, /Error: abc/)
})
test('keeps non-error cause', () => {
const err = Error('foo')
err.cause = 'abc'
const serialized = serializer(err)
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'foo')
assert.strictEqual(serialized.cause, 'abc')
})
test('prevents infinite recursion', () => {
const err = Error('foo')
err.inner = err
const serialized = serializer(err)
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'foo')
assert.match(serialized.stack, /err\.test\.js:/)
assert.ok(!serialized.inner)
})
test('cleans up infinite recursion tracking', () => {
const err = Error('foo')
const bar = Error('bar')
err.inner = bar
bar.inner = err
serializer(err)
const serialized = serializer(err)
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'foo')
assert.match(serialized.stack, /err\.test\.js:/)
assert.ok(serialized.inner)
assert.strictEqual(serialized.inner.type, 'Error')
assert.strictEqual(serialized.inner.message, 'bar')
assert.match(serialized.inner.stack, /Error: bar/)
assert.ok(!serialized.inner.inner)
})
test('err.raw is available', () => {
const err = Error('foo')
const serialized = serializer(err)
assert.strictEqual(serialized.raw, err)
})
test('redefined err.constructor doesnt crash serializer', () => {
function check (a, name) {
assert.strictEqual(a.type, name)
assert.strictEqual(a.message, 'foo')
}
const err1 = TypeError('foo')
err1.constructor = '10'
const err2 = TypeError('foo')
err2.constructor = undefined
const err3 = Error('foo')
err3.constructor = null
const err4 = Error('foo')
err4.constructor = 10
class MyError extends Error {}
const err5 = new MyError('foo')
err5.constructor = undefined
check(serializer(err1), 'TypeError')
check(serializer(err2), 'TypeError')
check(serializer(err3), 'Error')
check(serializer(err4), 'Error')
// We do not expect 'MyError' because err5.constructor has been blown away.
// `err5.name` is 'Error' from the base class prototype.
check(serializer(err5), 'Error')
})
test('pass through anything that does not look like an Error', () => {
function check (a) {
assert.strictEqual(serializer(a), a)
}
check('foo')
check({ hello: 'world' })
check([1, 2])
})
test('can wrap err serializers', () => {
const err = Error('foo')
err.foo = 'foo'
const serializer = wrapErrorSerializer(function (err) {
delete err.foo
err.bar = 'bar'
return err
})
const serialized = serializer(err)
assert.strictEqual(serialized.type, 'Error')
assert.strictEqual(serialized.message, 'foo')
assert.match(serialized.stack, /err\.test\.js:/)
assert.ok(!serialized.foo)
assert.strictEqual(serialized.bar, 'bar')
})
test('serializes aggregate errors', { skip: !global.AggregateError }, () => {
const foo = new Error('foo')
const bar = new Error('bar')
for (const aggregate of [
new AggregateError([foo, bar], 'aggregated message'), // eslint-disable-line no-undef
{ errors: [foo, bar], message: 'aggregated message', stack: 'err.test.js:' }
]) {
const serialized = serializer(aggregate)
assert.strictEqual(serialized.message, 'aggregated message')
assert.strictEqual(serialized.aggregateErrors.length, 2)
assert.strictEqual(serialized.aggregateErrors[0].message, 'foo')
assert.strictEqual(serialized.aggregateErrors[1].message, 'bar')
assert.match(serialized.aggregateErrors[0].stack, /^Error: foo/)
assert.match(serialized.aggregateErrors[1].stack, /^Error: bar/)
assert.match(serialized.stack, /err\.test\.js:/)
}
})
+477
View File
@@ -0,0 +1,477 @@
'use strict'
const { tspl } = require('@matteo.collina/tspl')
const http = require('node:http')
const { test } = require('node:test')
const serializers = require('../lib/req')
const { wrapRequestSerializer } = require('../')
test('maps request', async (t) => {
const p = tspl(t, { plan: 2 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
const serialized = serializers.mapHttpRequest(req)
p.ok(serialized.req)
p.ok(serialized.req.method)
res.end()
}
await p.completed
})
test('does not return excessively long object', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
const serialized = serializers.reqSerializer(req)
p.strictEqual(Object.keys(serialized).length, 6)
res.end()
}
await p.completed
})
test('req.raw is available', async (t) => {
const p = tspl(t, { plan: 2 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
req.foo = 'foo'
const serialized = serializers.reqSerializer(req)
p.ok(serialized.raw)
p.strictEqual(serialized.raw.foo, 'foo')
res.end()
}
await p.completed
})
test('req.raw will be obtained in from input request raw property if input request raw property is truthy', async (t) => {
const p = tspl(t, { plan: 2 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
req.raw = { req: { foo: 'foo' }, res: {} }
const serialized = serializers.reqSerializer(req)
p.ok(serialized.raw)
p.strictEqual(serialized.raw.req.foo, 'foo')
res.end()
}
await p.completed
})
test('req.id defaults to undefined', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
const serialized = serializers.reqSerializer(req)
p.strictEqual(serialized.id, undefined)
res.end()
}
await p.completed
})
test('req.id has a non-function value', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
const serialized = serializers.reqSerializer(req)
p.strictEqual(typeof serialized.id === 'function', false)
res.end()
}
await p.completed
})
test('req.id will be obtained from input request info.id when input request id does not exist', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
req.info = { id: 'test' }
const serialized = serializers.reqSerializer(req)
p.strictEqual(serialized.id, 'test')
res.end()
}
await p.completed
})
test('req.id has a non-function value with custom id function', async (t) => {
const p = tspl(t, { plan: 2 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
req.id = function () { return 42 }
const serialized = serializers.reqSerializer(req)
p.strictEqual(typeof serialized.id === 'function', false)
p.strictEqual(serialized.id, 42)
res.end()
}
await p.completed
})
test('req.url will be obtained from input request req.path when input request url is an object', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
req.path = '/test'
const serialized = serializers.reqSerializer(req)
p.strictEqual(serialized.url, '/test')
res.end()
}
await p.completed
})
test('req.url will be obtained from input request url.path when input request url is an object', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
req.url = { path: '/test' }
const serialized = serializers.reqSerializer(req)
p.strictEqual(serialized.url, '/test')
res.end()
}
await p.completed
})
test('req.url will be obtained from input request url when input request url is not an object', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
req.url = '/test'
const serialized = serializers.reqSerializer(req)
p.strictEqual(serialized.url, '/test')
res.end()
}
await p.completed
})
test('req.url will be empty when input request path and url are not defined', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
const serialized = serializers.reqSerializer(req)
p.strictEqual(serialized.url, '/')
res.end()
}
await p.completed
})
test('req.url will be obtained from input request originalUrl when available', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
req.originalUrl = '/test'
const serialized = serializers.reqSerializer(req)
p.strictEqual(serialized.url, '/test')
res.end()
}
await p.completed
})
test('req.url will be obtained from input request url when req path is a function', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
req.path = function () {
throw new Error('unexpected invocation')
}
req.url = '/test'
const serialized = serializers.reqSerializer(req)
p.strictEqual(serialized.url, '/test')
res.end()
}
await p.completed
})
test('req.url being undefined does not throw an error', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
req.url = undefined
const serialized = serializers.reqSerializer(req)
p.strictEqual(serialized.url, undefined)
res.end()
}
await p.completed
})
test('can wrap request serializers', async (t) => {
const p = tspl(t, { plan: 3 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
const serailizer = wrapRequestSerializer(function (req) {
p.ok(req.method)
p.strictEqual(req.method, 'GET')
delete req.method
return req
})
function handler (req, res) {
const serialized = serailizer(req)
p.ok(!serialized.method)
res.end()
}
await p.completed
})
test('req.remoteAddress will be obtained from request socket.remoteAddress as fallback', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
req.socket = { remoteAddress: 'http://localhost' }
const serialized = serializers.reqSerializer(req)
p.strictEqual(serialized.remoteAddress, 'http://localhost')
res.end()
}
await p.completed
})
test('req.remoteAddress will be obtained from request info.remoteAddress if available', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
req.info = { remoteAddress: 'http://localhost' }
const serialized = serializers.reqSerializer(req)
p.strictEqual(serialized.remoteAddress, 'http://localhost')
res.end()
}
await p.completed
})
test('req.remotePort will be obtained from request socket.remotePort as fallback', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
req.socket = { remotePort: 3000 }
const serialized = serializers.reqSerializer(req)
p.strictEqual(serialized.remotePort, 3000)
res.end()
}
await p.completed
})
test('req.remotePort will be obtained from request info.remotePort if available', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
req.info = { remotePort: 3000 }
const serialized = serializers.reqSerializer(req)
p.strictEqual(serialized.remotePort, 3000)
res.end()
}
await p.completed
})
test('req.query is available', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
req.query = '/foo?bar=foobar&bar=foo'
const serialized = serializers.reqSerializer(req)
p.strictEqual(serialized.query, '/foo?bar=foobar&bar=foo')
res.end()
}
await p.completed
})
test('req.params is available', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (req, res) {
req.params = '/foo/bar'
const serialized = serializers.reqSerializer(req)
p.strictEqual(serialized.params, '/foo/bar')
res.end()
}
await p.completed
})
+120
View File
@@ -0,0 +1,120 @@
'use strict'
/* eslint-disable no-prototype-builtins */
const { tspl } = require('@matteo.collina/tspl')
const http = require('node:http')
const { test } = require('node:test')
const serializers = require('../lib/res')
const { wrapResponseSerializer } = require('../')
test('res.raw is not enumerable', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (_req, res) {
const serialized = serializers.resSerializer(res)
p.strictEqual(serialized.propertyIsEnumerable('raw'), false)
res.end()
}
await p.completed
})
test('res.raw is available', async (t) => {
const p = tspl(t, { plan: 2 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (_req, res) {
res.statusCode = 200
const serialized = serializers.resSerializer(res)
p.ok(serialized.raw)
p.strictEqual(serialized.raw.statusCode, 200)
res.end()
}
await p.completed
})
test('can wrap response serializers', async (t) => {
const p = tspl(t, { plan: 3 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
const serializer = wrapResponseSerializer(function (res) {
p.ok(res.statusCode)
p.strictEqual(res.statusCode, 200)
delete res.statusCode
return res
})
function handler (_req, res) {
res.end()
res.statusCode = 200
const serialized = serializer(res)
p.ok(!serialized.statusCode)
}
await p.completed
})
test('res.headers is serialized', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (_req, res) {
res.setHeader('x-custom', 'y')
const serialized = serializers.resSerializer(res)
p.strictEqual(serialized.headers['x-custom'], 'y')
res.end()
}
await p.completed
})
test('req.url will be obtained from input request url when input request url is not an object', async (t) => {
const p = tspl(t, { plan: 1 })
const server = http.createServer(handler)
server.unref()
server.listen(0, () => {
http.get(server.address(), () => {})
})
t.after(() => server.close())
function handler (_req, res) {
const serialized = serializers.resSerializer(res)
p.strictEqual(serialized.statusCode, null)
res.end()
}
await p.completed
})
+71
View File
@@ -0,0 +1,71 @@
import {IncomingMessage, ServerResponse} from "http";
import {
err,
errWithCause,
req,
res,
SerializedError,
SerializedRequest,
wrapErrorSerializer,
wrapRequestSerializer,
wrapResponseSerializer,
SerializedResponse
} from '../../';
const customErrorSerializer = (error: SerializedError) => {
return {
myOwnError: {
data: `${error.type}-${error.message}\n\n${error.stack}`,
}
};
};
const customRequestSerializer = (req: SerializedRequest) => {
const {
headers,
id,
method,
raw,
remoteAddress,
remotePort,
url,
query,
params,
} = req;
return {
myOwnRequest: {
data: `${method}-${id}-${remoteAddress}-${remotePort}-${url}`,
headers,
raw,
}
};
};
const customResponseSerializer = (res: SerializedResponse) => {
const {headers, raw, statusCode} = res;
return {
myOwnResponse: {
data: statusCode,
headers,
raw,
}
};
};
const fakeError = new Error('A fake error for testing');
const serializedError: SerializedError = err(fakeError);
const mySerializer = wrapErrorSerializer(customErrorSerializer);
const fakeErrorWithCause = new Error('A fake error for testing with cause', { cause: new Error('An inner fake error') });
const serializedErrorWithCause: SerializedError = errWithCause(fakeError);
const request: IncomingMessage = {} as IncomingMessage
const serializedRequest: SerializedRequest = req(request);
const myReqSerializer = wrapRequestSerializer(customRequestSerializer);
const response: ServerResponse = {} as ServerResponse
const myResSerializer = wrapResponseSerializer(customResponseSerializer);
const serializedResponse = res(response);
myResSerializer(response)
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "es6",
"lib": [ "es2022" ],
"module": "commonjs",
"noEmit": true,
"strict": true
},
"include": [
"./test/types/*.test-d.ts",
"./index.d.ts"
]
}
View File
Whitespace-only changes.
+1
View File
@@ -0,0 +1 @@
*
Generated Vendored
+1
View File
@@ -0,0 +1 @@
getpino.io
+30
View File
@@ -0,0 +1,30 @@
# Pino is an OPEN Open Source Project
## What?
Individuals making significant and valuable contributions are given commit-access to the project to contribute as they see fit. This project is more like an open wiki than a standard guarded open source project.
## Rules
Before you start coding, please read [Contributing to projects with git](https://jrfom.com/posts/2017/03/08/a-primer-on-contributing-to-projects-with-git/).
Notice that as long as you don't have commit-access to the project, you have to fork the project and open PRs from the feature branches of the forked project.
There are a few basic ground-rules for contributors:
1. **No `--force` pushes** on `master` or modifying the Git history in any way after a PR has been merged.
1. **Non-master branches** ought to be used for ongoing work.
1. **Non-trivial changes** ought to be subject to an **internal pull-request** to solicit feedback from other contributors.
1. All pull-requests for new features **must** target the `master` branch. PRs to fix bugs in LTS releases are also allowed.
1. Contributors should attempt to adhere to the prevailing code-style.
1. 100% code coverage
## Releases
Declaring formal releases remains the prerogative of the project maintainer.
## Changes to this arrangement
This is an experiment and feedback is welcome! This document may also be subject to pull-requests or changes by contributors where you believe you have something valuable to add or change.
-----------------------------------------
Loaded 100 of 415 files, more files were not shown because too many files have changed in this diff. Show more