Skip to content
Fresh Jots
· 9 min read
Write to Fresh Jots from TypeScript — typed responses, a small SDK

Write to Fresh Jots from TypeScript — typed responses, a small SDK

A spin-off from Write a note from any project, focused on TypeScript. Two approaches:

- **`npm install freshjots`** — the fast path. Types ship in the package as of v0.2.0; nothing to configure.
- **Hand-roll a typed `Client`** — when you want strict typing end-to-end, full control over the HTTP layer, branded error codes, and tree-shakable single-method imports.

Both end up at the same place: `await client.append("log", "deploy ok")` with full editor autocomplete on `note.plain_body`, `note.byte_size`, and `ApiError.code`. You'll need a Fresh Jots API token (`FRESHJOTS_TOKEN`). If you don't have one, see [Get & set your Fresh Jots API token](/blog/getting-and-setting-the-api-token).

Approach A — `npm install freshjots` (recommended)

```sh
npm install freshjots
```
Types ship in the package as of v0.2.0 — no `@types/freshjots`, no `.d.ts` to write. Just import the typed surface:
```ts
import { Client, ApiError, type Note, type NoteSummary, type ApiErrorCode } from "freshjots";

const client = new Client();
const note: Note = await client.note("cron-jobs-prod");
//        ^? Note — full autocomplete on plain_body, byte_size, last_appended_at, …

console.log(note.plain_body);  // typed as string
console.log(note.byte_size);   // typed as number
```
This is the recommended path for most projects: zero setup, full strict-mode typing, no parallel codepath to maintain. Requires TypeScript 4.5 or later (anything that understands the `"types"` field in `package.json`'s `"exports"` map).

Approach B — hand-roll a typed `Client`

If you want a smaller bundle (the `freshjots` package brings in `process.env` references that some bundlers carry through), or full control over the HTTP layer, hand-roll the client in ~60 lines of TypeScript:
```ts
// src/fjots.ts
const BASE = "https://freshjots.com/api/v1";

export type ApiErrorCode =
  | "unauthenticated" | "forbidden" | "not_found" | "validation_failed"
  | "cap_exceeded" | "storage_cap_exceeded" | "content_too_large"
  | "content_type_mismatch" | "rate_limited" | "unknown";

export class ApiError extends Error {
  status: number;
  code: ApiErrorCode;
  details?: unknown;

  constructor(opts: { status: number; code: ApiErrorCode; message: string; details?: unknown }) {
    super(opts.message);
    this.name = "ApiError";
    this.status = opts.status;
    this.code = opts.code;
    this.details = opts.details;
  }
}

export interface Note {
  id: number;
  filename: string;
  title: string;
  format: "plain" | "rich";
  plain_body: string;
  byte_size: number;
  last_appended_at: string | null;
  // ... add more fields as you need them
}

export interface ClientOptions {
  token: string;
  baseUrl?: string;
  fetch?: typeof fetch;  // injectable for testing
}

export class Client {
  private token: string;
  private baseUrl: string;
  private fetcher: typeof fetch;

  constructor(opts: ClientOptions) {
    if (!opts.token) throw new Error("token required");
    this.token = opts.token;
    this.baseUrl = opts.baseUrl ?? BASE;
    this.fetcher = opts.fetch ?? fetch;
  }

  async append(filename: string, text: string): Promise<Note> {
    const path = `/notes/by-filename/${encodeURIComponent(filename)}/append`;
    return this.request<Note>("POST", path, { text });
  }

  async note(filename: string): Promise<Note> {
    const path = `/notes/by-filename/${encodeURIComponent(filename)}`;
    const { note } = await this.request<{ note: Note }>("GET", path);
    return note;
  }

  private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
    const headers: Record<string, string> = {
      Authorization: `Bearer ${this.token}`,
    };
    if (body !== undefined) headers["Content-Type"] = "application/json";

    const res = await this.fetcher(`${this.baseUrl}${path}`, {
      method,
      headers,
      body: body !== undefined ? JSON.stringify(body) : undefined,
    });
    const text = await res.text();
    const data = text ? JSON.parse(text) : {};

    if (!res.ok) {
      const err = data.error ?? {};
      throw new ApiError({
        status: res.status,
        code: err.code ?? "unknown",
        message: err.message ?? "request failed",
        details: err.details,
      });
    }
    return data as T;
  }
}
```

Inject `fetch` for tests:
```ts
import { Client } from "./fjots";

const fakeFetch: typeof fetch = async () =>
  new Response(JSON.stringify({ plain_body: "ok", filename: "log" }), { status: 200 });

const client = new Client({ token: "mn_test", fetch: fakeFetch });
const note = await client.note("log");  // no network in this test
```

Hand-roll if you need it; reach for the package if you don't.

---

## Branching on errors — discriminated by `code`

Both approaches give you a typed `ApiError.code`, which makes error branches exhaustive:

```ts
import { ApiError } from "freshjots";

try {
  await client.append("daily-log", entry);
} catch (e) {
  if (!(e instanceof ApiError)) throw e;

  switch (e.code) {
    case "rate_limited":
      await new Promise(r => setTimeout(r, 60_000));
      await client.append("daily-log", entry);
      break;
    case "cap_exceeded":
    case "storage_cap_exceeded":
      opsAlert(`Fresh Jots ${e.code}; dropping log entry.`);
      break;
    case "unauthenticated":
    case "forbidden":
      throw new Error("token bad — rotate at /settings/api_tokens");
    default:
      throw e;
  }
}
```

If you add a new code to the API later, TypeScript's exhaustiveness check (with a `never`-typed default) will flag every `switch` block that hasn't handled it. Pin error handling at the type level, not at runtime.

1. Patterns that work

A. Vite — browser app with a typed `fetch`

In a Vite app, `import.meta.env.VITE_FRESHJOTS_TOKEN` is the standard way to expose env vars to client code. **But — same caveat as the [JavaScript post](/blog/write-a-note-from-any-project) — never put a personal API token in browser code that real users run.** The token gives full read-write to your notes; anyone with browser DevTools can grab it.

Safe patterns:

- **Vite calls your own server, the server calls Fresh Jots.** The token lives on the server.
- **Per-user tokens** (Team tier). Each end-user mints their own token; the browser holds *their* token.
- **Internal tools** running on `localhost` or behind SSO where the user is trusted.

If you've satisfied one of the above, browser fetch is straight-forward:
```ts
// src/fjots-browser.ts
import { Client } from "./fjots";  // hand-roll from Approach B

const token = import.meta.env.VITE_FRESHJOTS_TOKEN as string;
export const fjots = new Client({ token });
```

The Fresh Jots API allows cross-origin requests from any origin (`origins "*"` on `/api/v1/*`), so no CORS proxy is needed on the server side.

B. Next.js Server Action — server-side, fully typed
```tsx
// app/deploy/actions.ts
"use server";

import { Client, ApiError } from "freshjots";

const fjots = new Client();  // reads process.env.FRESHJOTS_TOKEN

export async function logDeploy(sha: string, status: "ok" | "fail") {
  try {
    await fjots.append("deploy-log", `${status} — sha=${sha}`);
  } catch (e) {
    if (e instanceof ApiError && e.code === "rate_limited") return;
    throw e;
  }
}
```

Server Actions run on the Node side of Next.js, so `process.env.FRESHJOTS_TOKEN` reads from your `.env.local` (or your hosting provider's secrets). The browser never sees the token.

C. Cloudflare Workers — strict-typed worker

```ts
// worker.ts
import { Client } from "freshjots";

export interface Env {
  FRESHJOTS_TOKEN: string;  // set with: wrangler secret put FRESHJOTS_TOKEN
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const fjots = new Client({ token: env.FRESHJOTS_TOKEN });
    const country = request.headers.get("cf-ipcountry") ?? "??";
    const path = new URL(request.url).pathname;
    await fjots.append("edge-events", `${request.method} ${path} from ${country}`);
    return new Response("ok");
  },
};
```

Cloudflare's `Env` typing means an unset `FRESHJOTS_TOKEN` is caught at build time, not at the first request.

D. tRPC procedure — log every mutation

```ts
// server/trpc.ts
import { initTRPC } from "@trpc/server";
import { Client } from "freshjots";

const fjots = new Client();
const t = initTRPC.create();

export const fjotsLoggedProcedure = t.procedure.use(async ({ path, type, next }) => {
  const result = await next();
  if (type === "mutation") {
    fjots
      .append("trpc-mutations", `${path} (${result.ok ? "ok" : "err"})`)
      .catch(() => {});
  }
  return result;
});
```

Every mutation built with `fjotsLoggedProcedure` writes to the `trpc-mutations` note. No-await + `.catch(() => {})` so the log call can't poison the response.

E. Bun + TypeScript — same code, faster runtime

```ts
import { Client } from "freshjots";

const fjots = new Client();
await fjots.append("bun-runs", `started at ${new Date().toISOString()}`);
```

Bun runs TS natively, no transpile step. `Bun.env.FRESHJOTS_TOKEN` is mirrored as `process.env.FRESHJOTS_TOKEN`, so the package reads it from either.

### Deno — explicit imports, explicit permissions

```ts
import { Client } from "npm:freshjots";

const fjots = new Client({ token: Deno.env.get("FRESHJOTS_TOKEN")! });
await fjots.append("deno-runs", "ok");
```

Run with `deno run --allow-net --allow-env script.ts`. Deno's permission system means an audit-conscious environment can explicitly grant only `--allow-net=freshjots.com`.

2. Loading the token

Three good options:

- **Shell profile** (`~/.bashrc`, `~/.zshrc`, `~/.config/fish/config.fish`) — see [Get & set your Fresh Jots API token](/blog/getting-and-setting-the-api-token). Best for local-machine work.
- **`.env` + Node's `--env-file` flag (20.6+)** — `node --env-file=.env script.ts`, no library. For older Node use `dotenv`; for Vite use `VITE_` prefix.
- **Container / orchestrator secret** — Kubernetes, ECS, Fly.io, Cloudflare Workers secrets, etc. Always for production.

Server-side TS reads `process.env.FRESHJOTS_TOKEN`. Browser TS reads whatever your bundler exposes (`import.meta.env.VITE_…` for Vite, `process.env.NEXT_PUBLIC_…` for Next.js — but **don't** expose your personal token to the browser, see Vite section above).

3. Going further

- **Source for the JS package the types ship from:** [github.com/Goran-Arsov/freshjots-js](https://github.com/Goran-Arsov/freshjots-js). MIT licensed, ~80 lines of JS + ~80 lines of `index.d.ts`.
- **Other languages — same pattern, different HTTP client.** See the hub: [Write a note from any project](/blog/write-a-note-from-any-project).
- **The CLI** — for one-off shell-from-TS scenarios. See [Notes from your terminal](/blog/notes-from-your-terminal).
- **Dead-man alerts** — pair a per-script note with `append_deadline_hours` and Fresh Jots emails you when the script goes silent. See [Everything you can do here](/blog/everything-you-can-do-here).

One `npm install` today, exhaustive error switches tomorrow, an entire ops telemetry pipeline by next month — all type-safe end to end, all over plain HTTP.

Share this post

Ready to start taking better notes? Sign up free