Palbase
Sign inGet started

Backend SDK

Localization

t() returns one of your backend's sentences in the language of the request that is running. You write the sentence in your project's source language; the sentence itself is the key. palbase build collects every t("…") into palbase/strings/ — one file per language — and palbase push ships it with the deploy. Each request is then answered in the language its Accept-Language asks for, falling back to the sentence as you wrote it.

Quick example

// modules/billing/billing.controller.ts
import { Controller, Get, Module, QueryParams, t, z } from "@palbase/backend";

export const ChargeQuery = z.object({ amount: z.string() });
export type ChargeQuery = z.infer<typeof ChargeQuery>;
export const Charge = z.object({ message: z.string() });
export type Charge = z.infer<typeof Charge>;

@Controller("/billing")
export class BillingController {
  @Get("/charge")
  charge(@QueryParams(ChargeQuery) q: ChargeQuery): Charge {
    return { message: t("Kartınız reddedildi ({{amount}} ₺)", { amount: q.amount }) };
  }
}

@Module({ controllers: [BillingController] })
export class BillingModule {}

The key is the sentence exactly as written, placeholder and all — Kartınız reddedildi ({{amount}} ₺) — never the text with a value in it. The value arrives with the call and is put in after the translation is chosen, so one key serves every amount.

palbase build --source tr          # once: starts palbase/strings/
palbase build --add en --add de    # a language: its file, every sentence missing
palbase build --translate          # fills the missing ones with AI, as needs_review
palbase push

GET /billing/charge?amount=50 with Accept-Language: en now gets {"message":"Your card was declined (50 ₺)"} and Content-Language: en; one asking for a language the table does not have gets the sentence as written, and Content-Language: tr.

Writing sentences

  • The first argument is the key AND the source-language text. Write it as a string literal (or a template literal with no ${…}) in a direct t(…) call so the build can collect it. Anything else still works at runtime but is served in the source language, and the build names the place.
  • {{name}} and {{ name }} are replaced with vars.name. Nothing is escaped — the text goes into JSON, not HTML. A missing, null or undefined value becomes the empty string.
  • Outside a request — at module load, in a job — there is no caller's language and t() returns the source text.

The table

palbase/strings/
  _meta.json   { "version": 2, "source": "tr" }
  en.json
  de.json
{
  "Kartınız reddedildi ({{amount}} ₺)": {
    "value": "Your card was declined ({{amount}} ₺)",
    "state": "translated"
  }
}
  • _meta.json names the language your sentences are written in; it is what makes the directory a table.
  • Every other language has one file, named by its tag exactly as the stack writes it (en.json, en-US.json) — en-us.json is refused with the name it should have. The source language has no file: its text is the key.
  • state is translated, needs_review or missing. missing never answers; the other two do.
  • Commit the directory. The stack validates it on every deploy and refuses a table that breaks these rules, naming the file. Other files in the directory (.DS_Store, a README.md) are not part of the table.

Languages

  • Add one: palbase build --add fr writes palbase/strings/fr.json with every sentence missing. Creating fr.json containing {} yourself and building does the same.
  • Remove one: delete its file.
  • Keep them in step: every palbase build adds new sentences to every file as missing, removes the ones your code no longer uses (and prints them), and never changes a translation you wrote. A translation whose {{…}} placeholders differ from its sentence's is printed as a warning.
  • The source language cannot be changed with --source: every key is a sentence in it.

Translating with AI

palbase build --translate sends every missing sentence to your project's stack, which translates it with your own OpenAI key. The key never leaves the stack, and the stack stores nothing.

palbase secret set OPENAI_API_KEY --stdin < openai-key.txt   # once per environment
palbase build --translate
  • A translation lands as needs_review: it is served at once, and marked so a person can read it and set translated.
  • A translation that drops or invents a {{placeholder}}, or comes back empty, is not written — the sentence stays missing in that language and the build names it.
  • Sentences go in batches, and each batch is written before the next is sent, so a failure part-way keeps what was already translated. The build then stops, says why and exits non-zero.

Which language a request gets

The request's Accept-Language, in the order of its q values; after each tag its base language (en-USen); last, the source language. The first language with an answer wins — the same chain the platform uses for everything it says itself. A JSON response whose handler called t() says which language it is in with Content-Language and Vary: Accept-Language.

Versions

  • The directory needs @palbase/backend 41.3.0 or later: the stack your project runs is the one your installed SDK names. With an older SDK the build keeps the single file palbase/strings.json and refuses --add and --translate, naming the upgrade.
  • A palbase/strings.json from an earlier CLI moves into palbase/strings/ on the first build; commit the new directory and the deletion.
  • Every machine and CI job that pushes the project needs a palbase CLI that carries the directory (0.71.0 or later). The stack refuses a push from an older one rather than ship a release that answers in one language, and a push refuses a stack that does not read the directory.

Testing a handler that translates

// modules/billing/billing.controller.test.ts
import { expect, it } from "bun:test";
import { loadStringsTable, withServices } from "@palbase/backend/test";

import { BillingController } from "./billing.controller";

it("answers in the caller's language", async () => {
  const strings = await loadStringsTable(process.cwd());
  const body = withServices({}, () => new BillingController().charge({ amount: "50" }), { strings, acceptLanguage: "en" });
  expect(body.message).toBe("Your card was declined (50 ₺)");
});

loadStringsTable(root) reads the same table the stack serves; withServices(…, { strings, acceptLanguage }) runs your code as a request with that Accept-Language would run.

  • Deploying — what palbase push carries
  • TestingwithServices() and the rest of the unit half
  • Secrets — where OPENAI_API_KEY lives