Migración Supabase → Firebase: Conectar un Formulario de Contacto a Firestore Sin Backend
Publié le 29 April 2026
- La escena : un formulario que no almacena nada
- ¿Por qué Firebase en lugar de Supabase?
- Fase 1 : Crear el proyecto Firebase
- Phase 2 : Reescribir el JavaScript de envío
- Fase 3: Limpiar el código muerto Supabase
- Fase 4 : Configurar el pie de página
- Fase 5 : Arquitectura final
- Lo que esta migración dice sobre el dogfooding
- Próximos pasos (backlog)
- Referencias
Durante meses, el formulario de contacto de este sitio funcionó sobre un mock JavaScript — una promesa a 85 % de éxito, un falso Firestore, cero datos almacenados. El plan inicial preveía un backend Supabase con Google Apps Script para las notificaciones por correo electrónico. Abandonado. Hoy cuento la migración a Firebase Firestore: creación del proyecto, reglas de seguridad, reescritura del JS, limpieza del código muerto de Supabase. Y por qué esta elección dice algo más amplio sobre la filosofía de desarrollo.
- toc
-
[]
La escena : un formulario que no almacena nada
Este sitio es generado por JBake, mi plugin Gradle`bakery`. Es 100 % estático — sin backend, sin base de datos. Excepto que tengo un formulario de contacto. La página`contact.html`existe, el HTML está listo (campos nombre, correo electrónico, teléfono, asunto, mensaje, validación HTML5, honeypot anti-spam), los estilos Bootstrap están en su lugar. Visualmente, todo es perfecto.
Excepto que al momento de la presentación, nada ocurre.
const firebaseMock = new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() < 0.85) {
resolve({ status: 201, message: 'Message stored in Firestore.' });
} else {
reject({ status: 500, message: 'Firestore write failed.' });
}
}, 1500);
});
Un mock. Una promesa que finge. El usuario ve un spinner, luego un mensaje « ¡Mensaje enviado con éxito! ». Pero los datos se van al vacío. Ningún mensaje se almacena en ningún sitio.
La situación es peor que un formulario roto — es un formulario que miente.
La herencia Supabase
El plan inicial, documentado en`content/draft/integration_formulaire_contact_supabase.adoc`, preveía :
-
Una base de Supabase con tabla`contacts`y Row Level Security
-
Una RPC`handle_contact_form`lado servidor
-
Un desencadenador SQL que llama a un webhook de Google Apps Script
-
Google Apps Script que envía un correo Gmail de notificación
El código JavaScript correspondiente todavía existe en`script.js`. Hay una clase`SupabaseManager`que inicializa un cliente Supabase con variables globales`SUPABASE_URL` et SUPABASE_KEY, y una clase`ContactFormHandler`que escucha el evento submit del formulario y llama`SupabaseManager.submitContactForm()`.
Problema: estas variables globales ya no se inyectan en el pie de página. Le`<script src="supabase-js">`ha sido retirado. El código llama`supabase.createClient()`sobre una variable`supabase`que ya no existe. Por lo tanto:
console.error : 'Supabase client library (supabase-js) is not loaded.'
No solo los datos no se almacenan, sino que el código de envío está muerto.
El doble envío fantasma
Para empeorar las cosas, hay unacompetencia silenciosaentre dos handlers en el mismo formulario :
-
`contact.js`Escucha el submit, llama al mock Firebase
-
script.js— via`ContactFormHandler`— escucha también el submit, llama`SupabaseManager`
Los dos hacen`event.preventDefault() + event.stopPropagation(). Como`contact.js`se carga primero en`footer.thyme, su manejador se adjunta en primer lugar. Bloquea la propagación.`ContactFormHandler`nunca se disparará.
No es ni siquiera un bug activo — es un zombie. Código que nunca tiene la oportunidad de ejecutarse.
Failed to generate image: PlantUML preprocessing failed: [From <input> (line 6) ] @startuml skinparam backgroundColor #FEFEFE title Estado Inicial — Formulario de Contacto rectangle "contact.thyme (HTML Bootstrap, honeypot)" as FORM ^^^^^ Syntax Error? (Assumed diagram type: activity) @startuml skinparam backgroundColor #FEFEFE title Estado Inicial — Formulario de Contacto rectangle "contact.thyme (HTML Bootstrap, honeypot)" as FORM rectangle "footer.thyme (Firebase SDK con config placeholder)" as FOOT rectangle "contact.js\n(Firebase simulado, 85% éxito)" as CONT rectangle "script.js\n(SupabaseManager + ContactFormHandler, muertos)" as SCRIPT rectangle "Usuario" as USER USER -> FORM : Soumission du formulaire FORM -> CONT : submit event (attaché en premier) CONT -> CONT : preventDefault + stopPropagation CONT -> CONT : mock Promise (1.5s, aléatoire) note right of CONT ⚠️ Aucune donnée stockée end note FORM ..> SCRIPT : submit event (bloqué par stopPropagation) note right of SCRIPT : ❌ Jamais déclenché note right of SCRIPT : ❌ supabase-js non chargé @enduml
¿Por qué Firebase en lugar de Supabase?
La decisión de migración está documentada en`AGENT.adoc` :
Firebase se elige ahora por las siguientes razones: mejor plan gratuito, Firestore nativo, Cloud Functions integradas, ecosistema de Google más adecuado. La implementación existente de Supabase está marcada como « ⚠️ Abandonado ».
Más allá del plan gratuito hay una razón arquitectónica. Este sitio vive en el ecosistema de Google: el repositorio de destino es`cheroliv.github.io`, el CNAME apunta a GitHub Pages, la compilación Gradle push a GitHub a través de JGit. Añadir un servicio de Google (Firebase) en lugar de un servicio de terceros (Supabase) reduce la superficie de dispersión.
Firestore en modo nativo (no modo Datastore) también está más cerca del modelo mental de documento NoSQL que tengo en mente: colecciones, documentos, campos tipados, marcas de tiempo del servidor, reglas de seguridad integradas.
Fase 1 : Crear el proyecto Firebase
Inicialización
Como la CLI de Firebase no está instalada en mi máquina, paso por la consola web :
-
Ir sobrehttps://console.firebase.google.com/[Consola de Firebase]
-
Crear un proyecto`cheroliv-contact`(o reutilizar un proyecto existente)
-
Activar Firestore en modo nativo (no Datastore)
-
Crear una base de datos en región`eur3`(Europa)
Para un uso minimalista como el nuestro (una sola colección, escritura pública), el modo nativo es la buena opción. No se necesitan reglas Datastore complejas.
Reglas de seguridad de Firestore
El formulario es público — cualquiera puede enviar un mensaje. Pero quiero limitar los abusos :
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /contact_messages/{messageId} {
// Lecture : admin uniquement (authentifié)
allow read: if request.auth != null;
// Écriture : publique, mais limitée
allow create: if request.auth == null
&& request.resource.data.name is string
&& request.resource.data.name.size() >= 1
&& request.resource.data.name.size() <= 100
&& request.resource.data.email is string
&& request.resource.data.email.matches('.*@.*\\..*')
&& request.resource.data.email.size() <= 254
&& request.resource.data.subject is string
&& request.resource.data.subject.size() >= 3
&& request.resource.data.subject.size() <= 200
&& request.resource.data.message is string
&& request.resource.data.message.size() >= 10
&& request.resource.data.message.size() <= 5000
&& request.resource.data.created_at == request.time
&& request.resource.data.user_agent is string
&& request.resource.data.user_agent.size() <= 500;
}
}
}
Puntos clave:
-
allow read— solo los usuarios autenticados pueden leer los mensajes (yo, a través de la consola de Firebase) -
allow create— cualquiera puede crear un documento, pero con validación de campos -
Validación del lado servidor : tamaños mín/máx, formato de email,
created_at`debe corresponder a`request.time(anti-falsificación) -
`user_agent`se envía para trazabilidad (no crítico pero útil)
Estas reglas son más estrictas que un simple`allow write: if true;`. Evitan que un atacante inyecte cargas útiles enormes o campos mal formados.
Phase 2 : Reescribir el JavaScript de envío
El contrato es sencillo:
-
Leer los datos del formulario
-
Comprobar el honeypot (campo)
hp_name— si está lleno, es un bot, simulamos un éxito sin enviar nada) -
llamar`addDoc(window.FIREBASE.collection(db, "contact_messages"), {…})`
-
Mostrar éxito o error
La dependencia `window.FIREBASE
en`footer.thyme`, un script de módulo inicializa el SDK de Firebase y expone un objeto global :
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.0/firebase-app.js";
import { getFirestore, collection, addDoc, serverTimestamp }
from "https://www.gstatic.com/firebasejs/11.6.0/firebase-firestore.js";
const firebaseConfig = { /* valeurs réelles */ };
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
window.__FIREBASE__ = { db, collection, addDoc, serverTimestamp };
</script>
Los scripts del módulo se ejecutan antes`DOMContentLoaded`, entonces`window.FIREBASE`Se garantiza que esté disponible cuando el handler`contact.js`Como precaución, agrego igualmente un polling de 5 segundos por si el CDN estuviera lento.
El nuevo `contact.js
document.addEventListener('DOMContentLoaded', function () {
'use strict';
const form = document.getElementById('contact-form');
if (!form) return;
const submitButton = form.querySelector('button[type="submit"]');
const successMessage = document.getElementById('contact-success-message');
const errorMessage = document.getElementById('contact-error-message');
// Éléments de validation
const nameInput = form.querySelector('input[name="name"]');
const emailInput = form.querySelector('input[name="email"]');
const phoneInput = form.querySelector('input[name="phone"]');
const subjectInput = form.querySelector('input[name="subject"]');
const messageInput = form.querySelector('textarea[name="message"]');
const honeypotInput = form.querySelector('input[name="hp_name"]');
/**
* Attend que window.__FIREBASE__ soit disponible.
* Timeout de 5 secondes — si le CDN Firebase est lent, on abandonne.
*/
function waitForFirebase(timeoutMs = 5000) {
return new Promise((resolve, reject) => {
if (window.__FIREBASE__) {
resolve(window.__FIREBASE__);
return;
}
const start = Date.now();
const interval = setInterval(() => {
if (window.__FIREBASE__) {
clearInterval(interval);
resolve(window.__FIREBASE__);
} else if (Date.now() - start > timeoutMs) {
clearInterval(interval);
reject(new Error('Firebase SDK non disponible après timeout'));
}
}, 100);
});
}
// --- Validation (identique à l'existant) ---
function validateForm() {
nameInput.setCustomValidity('');
emailInput.setCustomValidity('');
if (phoneInput) phoneInput.setCustomValidity('');
subjectInput.setCustomValidity('');
messageInput.setCustomValidity('');
if (nameInput.value.trim().length < 1) {
nameInput.setCustomValidity('Veuillez saisir votre nom.');
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(emailInput.value.trim())) {
emailInput.setCustomValidity('Veuillez saisir une adresse email valide.');
}
if (phoneInput && phoneInput.value.trim() !== '') {
const phonePattern = /^\d{10,15}$/;
if (!phonePattern.test(phoneInput.value.trim())) {
phoneInput.setCustomValidity('Veuillez saisir un numéro valide (10 à 15 chiffres).');
}
}
if (subjectInput.value.trim().length < 3) {
subjectInput.setCustomValidity('Veuillez saisir un sujet (3 caractères minimum).');
}
if (messageInput.value.trim().length < 10) {
messageInput.setCustomValidity('Veuillez saisir un message (10 caractères minimum).');
}
form.classList.add('was-validated');
return form.checkValidity();
}
// --- Handler de soumission ---
form.addEventListener('submit', async function (event) {
event.preventDefault();
event.stopPropagation();
if (!validateForm()) return;
// Honeypot : si rempli, simuler un succès sans rien envoyer
if (honeypotInput && honeypotInput.value.trim() !== '') {
successMessage.style.display = 'block';
form.reset();
form.classList.remove('was-validated');
return;
}
// UI : état d'envoi
submitButton.disabled = true;
submitButton.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Envoi en cours...
`;
successMessage.style.display = 'none';
errorMessage.style.display = 'none';
try {
const fb = await waitForFirebase();
const messagesCollection = fb.collection(fb.db, 'contact_messages');
await fb.addDoc(messagesCollection, {
name: nameInput.value.trim(),
email: emailInput.value.trim(),
phone: phoneInput ? phoneInput.value.trim() : '',
subject: subjectInput.value.trim(),
message: messageInput.value.trim(),
created_at: fb.serverTimestamp(),
user_agent: navigator.userAgent.substring(0, 500)
});
successMessage.style.display = 'block';
form.reset();
form.classList.remove('was-validated');
} catch (error) {
console.error('Erreur Firestore:', error);
errorMessage.style.display = 'block';
} finally {
submitButton.disabled = false;
submitButton.innerHTML = `
<i class="bi bi-send me-2"></i>
Envoyer le Message
`;
}
}, false);
});
Los cambios respecto al mock :
-
waitForFirebase()— polling con timeout, robusto aunque el CDN esté lento -
honeypot— si el campo oculto`hp_name`está lleno, simular un éxito sin apelación Firestore. El bot cree haber tenido éxito pero nada está almacenado -
addDoc(collection, {…})— verdadero llamada Firestore con`serverTimestamp()` etuser_agent -
Gestión de errores con`try/catch`asíncrono
-
Limpieza del`finally`(restauración del botón)
|
¿Por qué? |
Fase 3: Limpiar el código muerto Supabase
`script.js`contiene 250 líneas de código muerto :
-
SupabaseManager(líneas 417-481) — 65 líneas -
ContactFormHandler(líneas 490-551) — 62 líneas -
Bloque de inicialización (líneas 645-654) — 10 líneas
Total : ~140 líneas a eliminar.
El bloque`DOMContentLoaded`crea un`SupabaseManager`luego un`ContactFormHandler`adjunto al formulario. Como se explicó anteriormente, este código nunca se ejecuta (bloqueado`contact.js`), y incluso si se ejecutara, fallaría (no se ha cargado el SDK de Supabase).
Elimino:
-
La clase`SupabaseManager`
-
La clase`ContactFormHandler`
-
El bloque de inicialización en`DOMContentLoaded`(líneas 645-654)
Le reste de script.js`está intacto :`ThemeManager, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, PhoneInputManager.
Fase 4 : Configurar el pie de página
`footer.thyme`ya tiene el boilerplate Firebase pero con valores placeholder. Estoy reemplazando:
const firebaseConfig = {
apiKey: "REMPLACER_PAR_VOTRE_API_KEY",
authDomain: "REMPLACER_PAR_VOTRE_AUTH_DOMAIN",
projectId: "REMPLACER_PAR_VOTRE_PROJECT_ID",
storageBucket: "REMPLACER_PAR_VOTRE_STORAGE_BUCKET",
messagingSenderId: "REMPLACER_PAR_VOTRE_SENDER_ID",
appId: "REMPLACER_PAR_VOTRE_APP_ID"
};
Por los valores reales recuperados desdeConfiguración del proyecto > General > Tus aplicaciones > Aplicación weben la consola de Firebase.
Los valores son sensibles`apiKey`es pública por diseño en Firebase, pero prefiero no hacerles commit en claro). Las almaceno en`site.yml`(ya en`.gitignore`) y el plugin`bakery`Los inyecta en el template por medio de una lógica que se debe agregar en el lado del build.
Por ahora, los pongo directamente en`footer.thyme`— el build`./gradlew serve`les cargará localmente. Durante el despliegue, migraré la inyección hacia`site.yml`o a una variable Gradle.
|
L'`apiKey`Firebase no esnoun secreto. Es pública por diseño. Lo que protege sus datos, son losreglas de seguridad de Firestore, no la clave API. No la pongan en uno`.env`cargado del lado del servidor — está destinado a ser expuesto al navegador. |
Fase 5 : Arquitectura final
Failed to generate image: PlantUML preprocessing failed: [From <input> (line 9) ]
@startuml
skinparam backgroundColor #FEFEFE
title Arquitectura Final — Formulario de Contacto Firebase
actor Utilisateur as USER
package "Navegador" #E8F5E9 {
rectangle "contact.thyme\n(HTML, trampa)" as FORM
rectangle "contact.js
^^^^^
Syntax Error? (Assumed diagram type: component)
@startuml
skinparam backgroundColor #FEFEFE
title Arquitectura Final — Formulario de Contacto Firebase
actor Utilisateur as USER
package "Navegador" #E8F5E9 {
rectangle "contact.thyme\n(HTML, trampa)" as FORM
rectangle "contact.js
(validación + Firestore)" as CONT
rectangle "footer.thyme\n(Firebase SDK init)" as FOOT
}
cloud "Firebase" #BBDEFB {
rectangle "Firestore
(contact_messages)" as FS
}
FORM --> CONT : submit event
CONT --> FOOT : window.__FIREBASE__.addDoc()
FOOT --> FS : Insert document\n(règles de sécurité validées)
note right of FS
Firestore Rules :
- create: public, validé
- read: auth uniquement
end note
@enduml
Lo que esta migración dice sobre el dogfooding
Este sitio es generado por mi propio plugin Gradle`bakery`. El formulario de contacto está dentro del sitio. La migración Supabase → Firebase está documentada en`AGENT.adoc`, se discute en el backlog, se prueba mediante`./gradlew serve`, y genera un artículo de blog (el que está leyendo).
Es puro dogfooding. El sitio es el producto del plugin, el plugin es el producto del desarrollador, el desarrollador documenta el proceso en el sitio mismo.
El bucle está cerrado.
El hecho de haber arrastrado un mock durante meses (sesiones completas donde el formulario mentía silenciosamente) me hizo darme cuenta de algo: el backlog de un sitio estático personal nunca está « terminado ». Siempre hay una US prioritaria, siempre un artículo en borrador, siempre una sección comentada en una plantilla. La disciplina no consiste en terminar todo — consiste en terminar lo que es visible para el usuario.
Un formulario de contacto roto, es peor que no tener ningún formulario. Es una promesa no cumplida.
Resumen de los cambios
Archivo |
Modificación |
impacto |
|
Creación del artículo |
Documentación |
|
Reescritura (mock → Firestore real) |
Funcional |
|
Supresión SupabaseManager + ContactFormHandler + bloque de inicialización |
Limpieza |
|
Sustitución de marcador de posición config → valores reales |
Configuración |
Próximos pasos (backlog)
-
Correo de notificación: Una Cloud Function`onCreate`sobre`contact_messages`que envía un correo electrónico vía SendGrid. El formulario almacena, pero no recibo notificación. Prioridad media — los mensajes son visibles en la consola de Firebase.
-
Limitación de velocidad del lado del cliente: Añadir un timestamp de localStorage para evitar los envíos múltiples en ráfaga. El honeypot bloquea a los bots ingenuos, un rate limiter bloquearía a los bots un poco más astutos.
-
Pruebas: Una prueba de Playwright que envía el formulario y verifica que el documento aparezca en Firestore. Por ahora, lo estoy probando manualmente vía`./gradlew serve`.