@treinta/previews
v0.2.0
Published
Official Node/TypeScript SDK for the Propie / Treinta Previews VM-as-a-Service platform — create and operate Firecracker preview VMs, stream build logs, run commands, upload files, and embed a browser widget.
Maintainers
Readme
@treinta/previews
Official Node / TypeScript SDK for the Propie / Treinta Previews VM-as-a-Service platform. Create and operate Firecracker preview VMs, detect stacks, stream build logs, run commands, upload files, and embed a browser preview widget.
- Core client (
@treinta/previews) — full REST surface, Node 18+ / any fetch runtime. - Server proxy (
@treinta/previews/proxy) — expose a safe, VM-scoped subset to browsers. - React widget (
@treinta/previews/react) — an embeddable<PreviewWidget>+ hooks.
Install
npm install @treinta/previews
# React widget also needs react + react-dom (peer deps):
npm install react react-domRequires Node >= 18 (global fetch). ESM + CommonJS builds are both published.
Environment variables
| Purpose | Variables (checked in order) |
| -------- | ------------------------------------------------------------------------------- |
| API key | TREINTA_PREVIEWS_API_KEY, PROPIE_API_KEY, PREVIEWS_API_KEY |
| Base URL | TREINTA_PREVIEWS_API_URL, PROPIE_API_URL (default https://previews.amapola.treinta.ai/api) |
Core quickstart
import { createPreviewsClient } from "@treinta/previews";
const client = createPreviewsClient({
apiKey: process.env.TREINTA_PREVIEWS_API_KEY, // optional — falls back to env
});
// 1. Detect the stack of a public repo (SSE, streamed progress).
const detection = await client.detect.public(
"https://github.com/vercel/next.js",
(msg) => console.log("[detect]", msg),
);
// 2. Create a VM.
const vm = await client.vms.create({
repoUrl: "https://github.com/vercel/next.js",
branch: detection.branch,
stack: detection.stack,
exposedPort: detection.exposedPort,
installCommand: detection.installCommand ?? undefined,
startCommand: detection.startCommand ?? undefined,
environmentVariables: { NODE_ENV: "production" },
});
// 3. Wait until it's live.
const ready = await client.vms.waitUntilRunning(vm.id, {
onStatus: (s) => console.log("status:", s),
});
console.log("live at", ready.url);
// 4. Stream logs.
for await (const event of client.vms.logs(vm.id)) {
if (event.event === "log") console.log(event.message);
if (event.event === "error") break;
}
// 5. Run a command (buffered).
const out = await client.vms.run(vm.id, "node -v");
console.log(out.stdout, `(exit ${out.exitCode}, ${out.durationMs}ms)`);
// 6. Upload files / round-trip env.
await client.vms.uploadFiles(vm.id, [{ path: "hello.txt", content: "hi" }]);
await client.vms.setEnv(vm.id, { FEATURE_FLAG: "on" });
// 7. Clean up.
await client.vms.destroy(vm.id);Create from a local folder (Node)
Zips the directory (honoring .gitignore, stripping secrets, enforcing the 50 MiB cap):
const vm = await client.vms.createFromFolder(
"/abs/path/to/project",
{ stack: "node20", exposedPort: 3000 },
{ onWarning: (w) => console.warn(w) },
);Provision a managed database
databases.create provisions a managed Postgres and returns the connection
string once — persist it immediately, it is never returned again (unlike
integrations.create, which links an existing connection string and never
reveals it). Requires the vms:write scope.
const db = await client.databases.create({ name: "my-app-db" });
console.log(db.connectionString); // revealed ONCE — store it now
console.log(db.id, db.host, db.database);
// Later: list databases (redacted — no connection string) or delete one.
const dbs = await client.integrations.list(); // kind: "database"
await client.integrations.delete(db.id);API reference
createPreviewsClient(opts?) → a client with request<T>() and these namespaces:
| Namespace | Methods |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| vms | list, get, create, createFromArchive, createFromFolder, restart, redeploy, revive, destroy, run, logs (async iterable), logsTail, getEnv (keys only — values are write-only), setEnv, getFirewall, setFirewall, uploadFiles, bandwidth, waitUntilRunning, persist, unpersist, renameSlug, slugAvailable, mintWidgetToken |
| snapshots | list, create, clone, delete |
| drives | list, create, delete |
| integrations | list, create, delete, tables, rows, deleteRow |
| databases | create (provision managed Postgres; connection string revealed once) |
| detect | public(repoUrl), zip(bytes) |
| accounts | current |
| teams | list, create, delete |
| keys | list(teamId), create(teamId, input), revoke(teamId, keyId) |
Failed requests throw PreviewsApiError (alias PropieApiError) with { status, code, message }.
Browser widget (@treinta/previews/react)
<PreviewWidget> renders a live preview plus optional logs / terminal / upload /
env panels. Authenticate with exactly one of endpoint, token, or
tokenProvider. Theme via --pw-* CSS variables (pass theme).
Path A — server proxy (recommended)
The browser never holds a key; a same-origin proxy injects it.
"use client";
import { PreviewWidget } from "@treinta/previews/react";
export function Preview({ vmId }: { vmId: string }) {
return (
<PreviewWidget
vmId={vmId}
endpoint="/api/previews" // your proxy mount (see below)
panels={{ preview: true, logs: true, terminal: true, order: ["preview", "terminal", "logs"] }}
theme={{ colorAccent: "#7c3aed", radius: "12px" }}
onReady={(vm) => console.log("live at", vm.url)}
/>
);
}Path B — widget token
Mint a short-lived pwt_ token server-side, hand it to the browser. Logs use
fetch + SSE (so the Authorization header can be sent).
"use client";
import { PreviewWidget } from "@treinta/previews/react";
// token minted server-side: await client.vms.mintWidgetToken(vmId, { capabilities: [...] })
export function Preview({ vmId, token }: { vmId: string; token: string }) {
return <PreviewWidget vmId={vmId} token={token} panels={["preview", "logs"]} />;
}Use tokenProvider={async () => fetchFreshToken()} for rotation.
Individual hooks (useVmStatus, useLogs, useRun, useUploadFiles, useEnv)
and panels (PreviewPanel, LogsPanel, TerminalPanel, UploadPanel,
EnvPanel) are exported for custom layouts. Build a WidgetClient from
proxyTransport({ endpoint }) or tokenTransport({ token, apiBaseUrl }).
Server proxy (@treinta/previews/proxy)
Exposes ONLY: GET /vms/:id, POST /vms/:id/run, POST /vms/:id/files,
GET /vms/:id/logs (SSE pass-through), and POST /vms/:id/widget-token. It
injects your server pvk_ key, enforces an origin allowlist + CORS preflight,
and gates per-VM access.
Next.js App Router
// app/api/previews/[...path]/route.ts
import { createPreviewsProxy } from "@treinta/previews/proxy";
const proxy = createPreviewsProxy({
apiKey: process.env.TREINTA_PREVIEWS_API_KEY!,
allowOrigins: ["https://app.example.com"],
allowVm: async (vmId) => await userOwnsVm(vmId), // your authz
mintDefaults: { capabilities: ["preview:read", "logs:read"], ttlSeconds: 900 },
});
export const GET = (req: Request) => proxy.handler(req);
export const POST = (req: Request) => proxy.handler(req);
export const OPTIONS = (req: Request) => proxy.handler(req);Node http
import { createServer } from "node:http";
import { createPreviewsProxy } from "@treinta/previews/proxy";
const proxy = createPreviewsProxy({ apiKey: process.env.TREINTA_PREVIEWS_API_KEY! });
createServer((req, res) => proxy.nodeHandler(req, res)).listen(8787);Security: without
allowVmthe proxy allows any VM in the key's project. Always passallowVm(andallowOriginsfor cross-origin widgets).
License
MIT
