Integrazione Server Personalizzato
Integrazione Server Personalizzato
Sezione intitolata “Integrazione Server Personalizzato”initializeRebaseBackend è una scorciatoia. Costruisce l’intero backend, lo monta
sulla tua app Hono sotto /api, aggancia il socket realtime al tuo server HTTP e
avvia il lavoro in background, in una sola chiamata. Quando è ciò che vuoi,
usala.
Quando non lo è, scendi di un gradino. Ogni parte di quella chiamata è un componente pubblico che puoi chiamare tu stesso: costruisci il backend, poi monta tutto o solo i gruppi di rotte che vuoi, ai percorsi che vuoi, e decidi quale processo avvia i timer. L’ultima sezione va un passo oltre e incorpora il solo driver Postgres in un’app Express.
Configurazione dell’Ambiente
Sezione intitolata “Configurazione dell’Ambiente”Rebase fornisce un’utilità centralizzata loadEnv() in @rebasepro/server che valida le variabili d’ambiente rispetto a uno schema Zod rigoroso. Eseguila dopo aver caricato il file .env:
import dotenv from "dotenv";import { loadEnv } from "@rebasepro/server";
// `rebase dev` and `rebase start` name the project's .env in DOTENV_CONFIG_PATH.// Unset, dotenv reads `.env` in the working directory.dotenv.config({ path: process.env.DOTENV_CONFIG_PATH, quiet: true });
export const env = loadEnv();Per aggiungere variabili tipizzate tue, estendi lo schema:
import { loadEnv, z } from "@rebasepro/server";
export const env = loadEnv({ extend: z.object({ SMTP_HOST: z.string().optional(), SMTP_PORT: z.string().default("587").transform(Number), STRIPE_SECRET_KEY: z.string(), })});// env.SMTP_HOST → string | undefined (fully typed)// env.STRIPE_SECRET_KEY → string (validated, required)L’importazione di z da @rebasepro/server è una novità. Nella versione 0.17 e
precedenti il pacchetto non esportava z: importalo da zod, allineando la major version a quella usata
dal runtime, e lascia che il tuo bundler deduplichi le due copie.
Comportamenti chiave:
- Genera
JWT_SECRETeREBASE_SERVICE_KEYin fase di sviluppo quando non sono impostati, permettendo l’avvio senza configurazione manuale, e li memorizza in.rebase-dev-secrets.jsoncosì un riavvio non disconnette nessuno. - Blocca i segreti generati automaticamente in produzione — è necessario impostarli esplicitamente.
- Valida che
CORS_ORIGINSoFRONTEND_URLsiano configurati in produzione. - Rifiuta uno schema
extendgenerato da una copia diversa di zod. - Lancia un
Errorche nomina ogni variabile mancante o errata, comeDATABASE_URL: is required, con i problemi rilevati da zod nella sua proprietàissues. Da 0.25 Su 0.24 lancia ilZodErrordi zod stesso, che non nomina alcuna variabile quando nulla lo intercetta.
Consulta .env.example nell’applicazione generata tramite scaffolding per l’elenco completo delle variabili supportate.
Un gradino sotto initializeRebaseBackend
Sezione intitolata “Un gradino sotto initializeRebaseBackend”Da 0.25 Su 0.24,
initializeRebaseBackend è l’unico ingresso, e nessuno dei componenti qui sotto è
esportato.
initializeRebaseBackend è queste chiamate, e nient’altro:
import { createRebaseBackend, installUnhandledRejectionHandler, setDefaultBackend } from "@rebasepro/server";
installUnhandledRejectionHandler();const backend = await createRebaseBackend({ ...options, provision: true });setDefaultBackend(backend);backend.mount(app, { basePath: "/api" });await backend.attachRealtime(server);backend.start();Lo shutdown() che restituisce chiude anche il server che gli è stato passato. Ecco la
stessa cosa scritta per esteso come backend/src/index.ts di un progetto, pronta da
modificare. rebase dev esegue quel file quando un progetto ce l’ha, al posto
dell’avvio proprio del runtime. Incollato in un progetto generato con lo scaffold, serve
ciò che serviva il runtime: l’API sulla porta scelta da rebase dev, accesso e
registrazione con la collection degli utenti del progetto, le funzioni in
backend/functions/, i cron job in backend/crons/, i bucket dichiarati in
config/resources.ts e il CORS di cui il pannello di amministrazione ha bisogno sulla
sua porta.
import { existsSync } from "node:fs";import { createServer } from "node:http";import path from "node:path";import { fileURLToPath } from "node:url";import dotenv from "dotenv";import { Hono } from "hono";import { cors } from "hono/cors";import { secureHeaders } from "hono/secure-headers";import { getRequestListener } from "@hono/node-server";import { CORS_EXPOSED_HEADERS, createRebaseBackend, installShutdownHandlers, installUnhandledRejectionHandler, isLocalhostOrigin, loadEnv, logger, resolveStorageSources, setDefaultBackend, type HonoEnv} from "@rebasepro/server";import { createPostgresAdapter, createPostgresDatabaseConnection, postgresDriverConfigFromEnv} from "@rebasepro/server-postgres";import { declaredStorageSources } from "@rebasepro/types";import "../../config/resources";import usersCollection from "../../config/collections/users";import { storageAuthorize } from "../../config/storage";
// dotenv never overrides a variable already set, so what `rebase dev` sets wins.dotenv.config({ path: process.env.DOTENV_CONFIG_PATH, quiet: true });const env = loadEnv();
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");const cronsDir = path.join(root, "backend/crons");
installUnhandledRejectionHandler();
const app = new Hono<HonoEnv>();
// Localhost in development, and the origins in CORS_ORIGINS or FRONTEND_URL.const origins = (env.CORS_ORIGINS || env.FRONTEND_URL || "").split(",").map((origin) => origin.trim());app.use("/*", cors({ origin: (origin) => origins.includes(origin) || (env.NODE_ENV !== "production" && isLocalhostOrigin(origin)) ? origin : null, credentials: true, exposeHeaders: [...CORS_EXPOSED_HEADERS]}));app.use("/*", secureHeaders());
const server = createServer({ maxHeaderSize: 64 * 1024 }, getRequestListener(app.fetch));const { db, pool, connectionString } = createPostgresDatabaseConnection(env.DATABASE_URL);
const backend = await createRebaseBackend({ collectionsDir: path.join(root, "config/collections"), functionsDir: path.join(root, "backend/functions"), cronsDir: existsSync(cronsDir) ? cronsDir : undefined, database: createPostgresAdapter(postgresDriverConfigFromEnv({ connection: db, connectionString })), auth: { collection: usersCollection, jwtSecret: env.JWT_SECRET, serviceKey: env.REBASE_SERVICE_KEY, allowRegistration: env.ALLOW_REGISTRATION, cookieAuth: { sameSite: "Lax" } }, storage: resolveStorageSources(process.env, declaredStorageSources(), path.join(root, "uploads")), storageAuthorize, history: true, provision: true});
setDefaultBackend(backend);backend.mount(app, { basePath: "/api" });await backend.attachRealtime(server);backend.start();
// The runtime answers health on both paths; an orchestrator probes `/health`.for (const healthPath of ["/health", "/api/health"]) { app.get(healthPath, async (c) => { const { healthy } = await backend.healthCheck(); return c.json({ status: healthy ? "ok" : "degraded" }, healthy ? 200 : 503); });}
// `rebase dev` reads this line to know the backend is up.server.listen(env.PORT, () => logger.info(`Server running at http://localhost:${env.PORT}`));installShutdownHandlers( { shutdown: (timeoutMs) => backend.shutdown(timeoutMs, { server }) }, { onCleanup: () => pool.end() });I percorsi sono quelli dello scaffold. Il file si affida a ciò che imposta rebase dev:
la porta scelta in PORT, il database gestito in DATABASE_URL e il .env del
progetto in DOTENV_CONFIG_PATH. loadEnv() valida ciò che contengono, e in sviluppo
genera JWT_SECRET quando non è impostato. Se ascolti su un’altra porta il pannello di
amministrazione non raggiunge il server; se salti loadEnv() e JWT_SECRET manca,
l’accesso è disattivato.
rebase dev aspetta anche la riga Server running at http://localhost:<port> prima di
dichiarare il backend attivo. Le opzioni auth sono quelle dello scaffold: il runtime
legge anche i provider OAuth, l’email e le altre variabili AUTH_*, quindi passa quelle
che usi, come descrive Autenticazione. Il file non serve
nemmeno il pannello di amministrazione compilato in produzione: lo fa l’entrypoint che
scrive rebase eject, con serveSPA, e che apre una connessione per ogni
database dichiarato in config/resources.ts.
createRebaseBackend accetta le stesse opzioni di initializeRebaseBackend, senza
app, server e basePath, e con provision obbligatorio. Costruisce tutto: apre il
database, configura autenticazione e storage, carica le funzioni e i cron job, e
costruisce ogni gruppo di rotte e il client in-process. Non monta nulla, non avvia alcun
timer e non aggiunge alcun listener di processo. Ognuna delle altre è una chiamata che
fai tu, oppure ometti:
| Chiamata | Cosa fa | Se la ometti |
|---|---|---|
installUnhandledRejectionHandler() |
Registra nei log una promise rejection che nessuno ha gestito, come una chiamata fire-and-forget in una funzione personalizzata, invece di lasciare che Node termini il processo. Vale per l’intero processo, quindi è una decisione del processo, non di un backend. Restituisce una funzione che lo disinstalla, oppure undefined se ne era già installato uno o se REBASE_EXIT_ON_UNHANDLED_REJECTION è impostata. |
una rejection non gestita termina il processo, il comportamento predefinito di Node |
setDefaultBackend(backend) |
Rende questo backend il default del processo: ciò a cui rispondono import { rebase } e il resto dell’API a livello di modulo fuori dalle richieste, dalle esecuzioni cron e dai job che un backend serve |
quell’API non ha alcun backend a cui rispondere lì |
backend.mount(app, { basePath }) |
Ogni gruppo di rotte sotto basePath (predefinito /api), dietro il middleware valido per l’intero processo: id delle richieste, limite del corpo, compressione, CSRF, log delle richieste e envelope JSON per gli errori e i percorsi sconosciuti. Inoltre il documento JWKS, il server MCP, la documentazione OpenAPI e gli alias di rotta legacy. |
nulla viene servito via HTTP; backend.rebase e backend.fetch funzionano comunque |
backend.attachRealtime(server, { path }) |
Il socket realtime, su server, che risponde solo agli upgrade su path |
non ci sono query live |
backend.start() |
Lo scheduler cron, i worker dei job, le pulizie dei token scaduti e della retention della cronologia, il campionatore delle metriche e l’audit RLS pianificato, ciascuno dove ownership lo assegna a questo processo. Si può chiamare due volte senza rischi. |
nessuno di essi viene eseguito, ed è ciò che vuole un processo che serve richieste in un deployment a processi suddivisi |
backend.shutdown(timeoutMs, { server }) |
Ferma ciò che start() e attachRealtime() hanno avviato, attendendo le esecuzioni cron e i job in corso. Chiude server solo se lo passi. |
Il path predefinito del socket è il percorso base passato a mount() privato di un
/api finale, quindi /api dà / e /x/api dà /x. È lì che si connette l’SDK,
perché si collega al proprio baseUrl mentre le sue richieste HTTP vanno a
${baseUrl}/api. Un upgrade verso qualsiasi altro percorso viene lasciato agli altri
listener upgrade del server, quindi la tua app può eseguire un proprio server
WebSocket sullo stesso http.Server.
Cosa può cambiare la creazione di un backend: provision
Sezione intitolata “Cosa può cambiare la creazione di un backend: provision”Da 0.25
La creazione di un backend può eseguire DDL, quindi createRebaseBackend ti chiede di
decidere:
provision |
Crea | Per |
|---|---|---|
true |
Tutto ciò che il backend serve: le tabelle delle collection e le loro policy RLS, l’acquisizione delle modifiche per il realtime, il ruolo con cui girano le richieste e le tabelle del framework (chiavi API, esecuzioni cron, job, metriche). | un deployment a processo singolo; ciò che passa initializeRebaseBackend |
false |
Nulla. Lo schema deve essere già presente, applicato da rebase db push, da una migrazione o da un altro processo. Una tabella mancante fa fallire la chiamata nel momento in cui viene usata. |
uno script, un test, un REPL, un processo che si limita a leggere |
{ collections: false, realtime: false } |
Le tabelle del framework e il ruolo, ma non lo schema delle collection né l’acquisizione delle modifiche. | un processo che, in un deployment suddiviso, non possiede lo schema |
È un’opzione e non un passaggio successivo, di proposito. Il driver legge le tabelle
mentre si apre e collega l’acquisizione delle modifiche a quelle che esistono in quel
momento. Un provision() chiamato dopo verrebbe eseguito quando il driver ha già
deciso che cosa esiste. REBASE_MIGRATE_ON_BOOT=none continua a disattivare lo schema
delle collection per qualsiasi backend del processo.
Montare solo i gruppi che vuoi
Sezione intitolata “Montare solo i gruppi che vuoi”Da 0.25
backend.routes contiene i gruppi di rotte. Ciascuno è una sotto-app Hono che puoi
montare da sola, a qualsiasi percorso:
import { Hono } from "hono";import { createRebaseBackend, type HonoEnv } from "@rebasepro/server";
const app = new Hono<HonoEnv>();const backend = await createRebaseBackend({ database: adapter, auth, collections, functions, provision: true });
app.route("/v2/data", backend.routes.data()); // the data API, at a path of your ownapp.route("/hooks", backend.routes.functions()); // getUser(c) and getDriver(c) work insidecollections è l’array che esporta il config/collections/index.ts di uno scaffold. Porta con sé le defaultSecurityRules di quell’indice, come fa collectionsDir, così il backend crea le policy che creerebbe rebase db push. Un array tuo porta solo le regole che dichiarano le sue collection, e provision: true rende accessibile solo agli amministratori una collection che non ne dichiara nessuna, eliminando le policy che Rebase aveva generato in precedenza per essa, su ogni server che condivide il database.
Da 0.25 L’indice dello scaffold applica i valori predefiniti al suo array. In un progetto generato con lo scaffold su 0.24, copia quel ciclo dal config/collections/index.ts di un nuovo scaffold, oppure passa collectionsDir.
| Gruppo | Normalmente a | Serve |
|---|---|---|
routes.auth() |
/api/auth |
accesso, sessioni, OAuth, reimpostazione della password, GET /config, le chiavi API personali del chiamante e il catalogo degli scope |
routes.data() |
/api/data |
le rotte REST delle collection e la loro cronologia |
routes.storage() |
/api/storage |
lo storage dei file, oppure il router che risponde STORAGE_NOT_CONFIGURED |
routes.functions() |
/api/functions |
le tue funzioni personalizzate, oppure il forwarder verso il processo che le serve |
routes.admin() |
/api/admin |
utenti e ruoli, chiavi di servizio, modifica dello schema, manutenzione dello storage, backup, l’audit RLS, l’export SQL, la casella di posta di sviluppo e i log |
routes.cron() |
/api/admin/cron |
la superficie di amministrazione del cron |
routes.meta() |
/api/meta |
il contratto del progetto che legge un SDK generato |
Ogni gruppo porta con sé il middleware di cui ha bisogno: il contesto di autenticazione
che mette user, driver e scopes del chiamante sulla richiesta, i suoi rate limit e
il suo controllo d’accesso admin. Quindi risponde allo stesso modo ovunque sia montato,
anche sotto un percorso parametrizzato come /:tenant/data. Un gruppo legge anche dove
è stato montato, così l’API dei dati instrada ancora ogni collection verso la sua
sorgente dati e l’elenco delle funzioni riporta i percorsi a cui sono realmente servite.
Ciò che un gruppo non porta con sé è il middleware valido per l’intero processo che
mount() mette davanti a tutto. Montando i gruppi uno per uno, aggiungi tu ciò che ti
serve: un limite del corpo (bodyLimit da hono/body-limit), la compressione e
l’envelope JSON degli errori:
import { errorHandler } from "@rebasepro/server";
app.onError(errorHandler);Ogni chiamata restituisce un router nuovo sugli stessi handler, quindi puoi montare un
gruppo in due posti. Chiedere un gruppo la cui superficie questo backend è stato creato
senza (surfaces: { data: false }) genera un errore, perché il backend non l’ha mai
costruito. La documentazione OpenAPI e il server MCP non hanno un gruppo: descrivono gli
URL del layout di mount(), quindi li serve solo mount().
Il backend senza un server
Sezione intitolata “Il backend senza un server”Da 0.25
Un backend che hai costruito può essere usato senza montare nulla. backend.rebase è il
suo client in-process, e backend.fetch(request) risponde a una Request come farebbe
mount() su /api:
const backend = await createRebaseBackend({ database: adapter, collections, provision: false });
const { data } = await backend.rebase.dataAsAdmin.posts.find({ limit: 5 });const res = await backend.fetch(new Request("http://local/api/meta/schema-version"));
await backend.shutdown();Le chiamate del piano di controllo su quel client (rebase.auth, rebase.admin,
rebase.functions e rebase.cron) raggiungono in-process le rotte del backend stesso.
Funzionano ovunque, o a prescindere dal fatto che tu abbia montato i gruppi, e il
middleware che hai aggiunto alla tua app non viene eseguito su di esse.
Per uno script che ha bisogno solo dei dati, openRebase
apre il database senza costruire affatto un backend.
Più backend nello stesso processo
Sezione intitolata “Più backend nello stesso processo”Da 0.25 Su 0.24 la configurazione
JWT, il modello di accesso, l’archivio delle chiavi API, l’archivio dei rate limit e
rebase sono globali di processo, e vince l’ultimo backend costruito, per tutti.
createRebaseBackend non scrive alcun globale di processo. Ogni backend tiene il proprio
stato in backend.services, quindi due backend nello stesso processo restano due:
ciascuno firma e verifica le sessioni con le proprie chiavi, valuta gli scope in base ai
propri ruoli, controlla le chiavi API nel proprio archivio e conta i rate limit nel
proprio archivio.
const a = await createRebaseBackend({ database: adapterA, auth: { jwtSecret: SECRET_A }, collections, provision: true });const b = await createRebaseBackend({ database: adapterB, auth: { jwtSecret: SECRET_B }, collections, provision: true });
app.route("/a/data", a.routes.data());app.route("/b/data", b.routes.data()); // a session from `a` is a 401 herebackend.services |
Che cos’è |
|---|---|
jwt |
Il servizio JWT del backend: firma e verifica sessioni, link di download e i suoi altri token, e serve il proprio JWKS |
access |
Il suo modello di accesso (da ruoli a scope) e il resolver per il proprietario di una chiave personale |
apiKeyStore |
Dove vengono controllate le sue chiavi API |
rateLimitStore, authLimiters |
I suoi contatori dei rate limit, e i limiter sulle sue rotte di autenticazione |
collections() |
Le collection che serve |
topics, queues |
I suoi runtime di topic e di code |
Un backend a cui non è stato dato alcun auth.jwtSecret, come uno il cui auth è un
adapter, non firma nulla di suo, quindi il suo services.jwt segue il default del
processo.
Dove va rebase. All’interno di una richiesta, di un’esecuzione cron o di un job che
un backend serve, rebase, sia importato da @rebasepro/server sia ricevuto come
ctx.rebase, è quel backend, e lo sono anche publish di un topic ed enqueue di una
coda. Altrove (ambito del modulo, uno script, un test) rispondono per il default del
processo, che imposta setDefaultBackend(backend) e che initializeRebaseBackend
imposta sul proprio backend. Lo stesso vale per il resto dell’API a livello di modulo:
verifyAccessToken e gli altri helper JWT, getAccessModel, verifyCredential ed
extractUserFromToken.
Tre componenti lavorano direttamente su un backend, e uno su nessun backend:
import { createJwtService, requireAuth, requireScope } from "@rebasepro/server";
// Mint a session for one of this backend's users, in a test or a login of your// own. `userId` is the user's id in the users collection.const token = await backend.services.jwt.generateAccessToken(userId, ["editor"]);
// Check a session token, an rk_ API key or the service key for a socket or a tunnel of your// own, as this backend's routes judge it: its uid, roles, scopes and kind, or null.const caller = await backend.verifyCredential(token);
// A route of your own, outside the groups, judged by this backend. The users collection// declares the app scope: auth: { scopes: { "project:deploy": { label: "Deploy projects" } } }.app.post("/deploy", (c, next) => { c.set("services", backend.services); return next(); }, requireAuth, requireScope("project:deploy"), (c) => c.json({ ok: true }));
// A JWT service on its own.const jwt = createJwtService({ secret: process.env.JWT_SECRET!, accessExpiresIn: "15m" });const payload = await jwt.verifyAccessToken(token);Una sessione generata viene controllata come qualsiasi altra a ogni richiesta, quindi il suo uid deve essere quello di un utente esistente: per un uid senza utente la richiesta viene rifiutata.
Da 0.25 verifyCredential arriva allo stesso verdetto delle rotte: una sessione da cui il proprietario si è disconnesso, un uid senza utente e un account disattivato restituiscono null, e i ruoli sono quelli che l’account possiede adesso. Lancia un errore quando il database non è raggiungibile. Su 0.24 controlla solo la firma, e legge i ruoli dal token.
Ogni gruppo di rotte imposta da sé c.var.services. Una rotta fuori dai gruppi che usa
requireAuth, requireScope o un altro guard di @rebasepro/server lo legge da lì, e
ripiega sul default del processo quando non è impostato. requireScope giudica il
chiamante che requireAuth ha identificato, quindi va dopo requireAuth: da solo rifiuta
ogni richiesta con un 401. Accetta uno scope integrato, oppure uno scope dell’app che il
backend dichiara sotto auth.scopes nella sua collection degli utenti, come nel commento
qui sopra.
Da 0.25 Un nome che non è uno scope, o uno scope integrato scritto male come data:raed, lancia un errore nel punto in cui la rotta viene registrata. Per uno scope dell’app che il backend che risponde non dichiara, ogni chiamante, anche un amministratore, riceve 500 SCOPE_NOT_DECLARED. Su 0.24 in entrambi i casi ogni chiamante riceve 403 SCOPE_MISSING.
Arresto
Sezione intitolata “Arresto”Da 0.25 Un backend creato con
createRebaseBackend è una novità; il resto di questa sezione vale anche per 0.24.
Da initializeRebaseBackend, passa il backend all’helper integrato invece di scrivere un
signal handler. Svuota le richieste HTTP, arresta lo scheduler cron, smonta il realtime,
chiude il pool SMTP quando nulla può più inviare, protegge da segnali ripetuti ed esce
forzatamente se l’arresto si blocca:
import { initializeRebaseBackend, installShutdownHandlers } from "@rebasepro/server";
const backend = await initializeRebaseBackend({ app, server, database: adapter, collections });installShutdownHandlers(backend, { onCleanup: () => pool.end() });Non combinarlo con un tuo server.close(): lo shutdown() di quel backend chiude
già il server che gli è stato passato, e una seconda chiusura porta a un deadlock. Un
backend creato con createRebaseBackend chiude un server solo quando glielo passi,
backend.shutdown(timeoutMs, { server }), come nell’esempio in cima a questa pagina.
Incorporare il driver Postgres in Express
Sezione intitolata “Incorporare il driver Postgres in Express”Un passo più in basso, @rebasepro/server-postgres funziona senza alcun server Rebase.
Dipende da Drizzle e dall’http.Server di Node, quindi puoi aprire il driver dei dati e
il socket realtime dentro Express, Fastify o Node semplice. A questo livello assembli tu
il backend: non ci sono rotte di autenticazione, né storage, né API di amministrazione, a
meno che non le costruisca tu.
1. Installazione
Sezione intitolata “1. Installazione”npm install @rebasepro/server-postgres @rebasepro/types express pg drizzle-orm2. Aprire il driver e il socket
Sezione intitolata “2. Aprire il driver e il socket”Da 0.25 openPostgresDriver
e postgresDriverConfigFromEnv sono esportati. Su 0.24, apri il driver con
createPostgresBootstrapper(pgConfig).initializeDriver(…), il cui internals non è
tipizzato.
import express from "express";import { createServer } from "node:http";import { sql } from "drizzle-orm";import { createPostgresDatabaseConnection, createPostgresWebSocket, openPostgresDriver, postgresDriverConfigFromEnv} from "@rebasepro/server-postgres";import { collections } from "./collections";
async function startServer() { const app = express();
// The socket needs the raw http.Server, so listen on it, not on app.listen. // 64 KB of headers, as the Rebase runtime takes: a vector search in the URL // outgrows Node's 16 KB. const server = createServer({ maxHeaderSize: 64 * 1024 }, app);
const { db, pool, connectionString } = createPostgresDatabaseConnection(process.env.DATABASE_URL!);
// The environment, read the way the Rebase server's boot reads it: // DATABASE_READ_URL, DATABASE_DIRECT_URL and the REALTIME_* variables. // Leave this call out and the driver reads no environment variable at all. const pgConfig = postgresDriverConfigFromEnv({ connection: db, connectionString });
// The collections' tables must already exist: the driver serves them and never creates them. // `provision` (default true) is the rest of its DDL: the role requests run as, branch metadata, // channel history and change capture. `provision: false` when none of that may run here. const { driver, internals } = await openPostgresDriver(pgConfig, { collections });
// The realtime socket. It answers upgrades on `path` only, so a WebSocket // server of your own can share the same http.Server. createPostgresWebSocket( server, internals.realtimeService, internals.driver, { requireAuth: true, jwtSecret: process.env.JWT_SECRET }, undefined, { path: "/" } );
app.use(express.json());
app.get("/api/health", (req, res) => { res.json({ status: "healthy" }); });
// A write through the driver app.post("/api/products", async (req, res) => { try { const result = await driver.save({ path: "products", values: req.body, status: "new" // required: "new" | "existing" | "copy" }); res.status(201).json({ success: true, data: result }); } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : "Internal Server Error" }); } });
// Drizzle directly, typed: `internals.db`, and `internals.readDb` when a // read replica is configured. app.get("/api/stats", async (req, res) => { const result = await internals.db.execute(sql`select count(*) from products`); res.json(result.rows); });
const port = process.env.PORT || 3000; server.listen(port);
// Graceful shutdown: stop listening, stop the LISTEN client, evict branch // pools, then end the main pool. const handleShutdown = (signal: string) => { console.log(`Shutdown triggered via ${signal}.`); server.close(async () => { try { await internals.realtimeService.stopListening(); await internals.poolManager?.shutdown(); await pool.end(); process.exit(0); } catch (err) { console.error("Error during graceful shutdown:", err); process.exit(1); } }); };
process.on("SIGTERM", () => handleShutdown("SIGTERM")); process.on("SIGINT", () => handleShutdown("SIGINT"));}
startServer();openPostgresDriver è ciò che createPostgresAdapter esegue quando il server Rebase si
avvia, dopo che postgresDriverConfigFromEnv ha letto l’ambiente nella configurazione.
Il suo secondo argomento dice che cosa aprire: le collections, se l’apertura può
eseguire il proprio DDL (provision, predefinito true) e quale parte ha questo processo
nel realtime (realtime: false per nessuna).
Non crea alcuna tabella delle collection, quindi su un database che non ne ha una POST /api/products risponde 500 “Table not found for collection ‘products’”. Crea prima le tabelle. Lo fa rebase db push, da un progetto Rebase il cui config/collections le dichiara. In questo server, await createPostgresAdapter(pgConfig).ensureCollectionSchema?.(collections) prima di openPostgresDriver aggiunge le tabelle e le colonne che mancano al database, come fa l’avvio del server Rebase, e non modifica nulla di ciò che esiste.
Concetti Chiave del Backend
Sezione intitolata “Concetti Chiave del Backend”Connessioni alle Read Replica
Sezione intitolata “Connessioni alle Read Replica”Imposta readConnectionString nella configurazione del driver, oppure la variabile d’ambiente DATABASE_READ_URL, che l’avvio del server e postgresDriverConfigFromEnv leggono in essa, e il driver apre un secondo connection pool verso la tua read replica, sotto internals.readDb. Le letture vanno alla replica; le scritture restano sul primario.
Da 0.25 L’opzione readConnectionString viene rispettata. Su 0.24 era dichiarata e ignorata, e funzionava solo DATABASE_READ_URL.
Integrazione con Drizzle
Sezione intitolata “Integrazione con Drizzle”Non devi scegliere tra Rebase e Drizzle. Il driver compila i tuoi schemi dinamicamente, e internals.db è il client Drizzle NodePgDatabase, tipizzato, per SQL raw o per i query builder type-safe di Drizzle accanto ai servizi REST di Rebase.
Drenaggio Corretto delle Connessioni (Graceful Draining)
Sezione intitolata “Drenaggio Corretto delle Connessioni (Graceful Draining)”Negli ambienti serverless o negli orchestratori (come Kubernetes), la chiusura dei pod può causare interruzioni anomale delle connessioni. Implementa sempre signal handler che richiamino internals.realtimeService.stopListening() (il quale chiude il client dedicato a pg LISTEN) e pool.end(), per prevenire il leak di slot di connessione nel tuo server di database.
CORS per un frontend nel browser
Sezione intitolata “CORS per un frontend nel browser”Un server personalizzato possiede la propria configurazione CORS, e un frontend
su un’altra origine può leggere solo gli header di risposta che espone.
Esponi quelli che l’SDK legge, altrimenti etagOf(row) lì è sempre
undefined — una scrittura con ifMatch parte allora senza If-Match — e un
429 perde il suo Retry-After:
import express from "express";import cors from "cors";import { CORS_EXPOSED_HEADERS } from "@rebasepro/server";
const app = express();app.use(cors({ origin: ["https://app.example.com"], credentials: true, exposedHeaders: [...CORS_EXPOSED_HEADERS]}));Da 0.25 CORS_EXPOSED_HEADERS è l’elenco che espone il runtime stesso, così un
header che l’SDK inizia a leggere in una versione successiva raggiunge il tuo frontend senza
modifiche qui. Sono ETag, Retry-After, X-Request-ID, i tre
header X-RateLimit-* e Preference-Applied, più Location,
Upload-Offset e Rebase-Upload-Result, con cui risponde un upload riprendibile (file oltre
8 MiB). Senza questi tre l’SDK non può seguire un upload da
un’altra origine, e invia ogni file come un’unica richiesta multipart. Sulla 0.24,
scrivi l’elenco a mano.
Il runtime di Rebase e il backend ottenuto con rebase eject inviano già questo elenco. Sulla 0.23 non espongono nessuno di questi header, quindi un frontend su un’altra origine non legge alcun ETag.
Correlati
Sezione intitolata “Correlati”- Script e console — i dati del progetto da uno script, da un test o da un REPL, senza alcun server
- Panoramica Backend — dove risiede ciascuna opzione quando viene invece avviato il runtime
- Processi Suddivisi — i ruoli che un server personalizzato deve riprodurre
- Configurazione dello Storage — le sorgenti che un server personalizzato deve risolvere autonomamente