Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/database-env-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/database-client": patch
---

Treat unreadable environment variables as unset instead of crashing when Deno runs without --allow-env
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ This is a Bun workspace monorepo with seven packages:

- **`@bunny.net/openapi-client`** (`packages/openapi-client/`) — Standalone, type-safe OpenAPI client for bunny.net, generated from OpenAPI specs. Zero CLI dependencies. Publishable to npm.
- **`@bunny.net/config`** (`packages/config/`) — Shared `bunny.jsonc` schemas (Zod), inferred types, JSON Schema generation, and API conversion functions. The root `BunnyConfigSchema` has optional `app` (Magic Containers) and `sites` (static sites) blocks; `BunnyAppConfigSchema` narrows it to require `app`. Used by the CLI and potentially other tools.
- **`@bunny.net/database-client`** (`packages/database-client/`) — Standalone SQL client for Bunny Database, for application code rather than the CLI. Speaks hrana-over-HTTP (`POST /v2/pipeline`) using only `fetch`, so it runs unchanged on Edge Scripting (Deno), Bun, and Node. Zero dependencies. **Server-side only, and documented as such:** an auth token is a bearer credential for the whole database and the client sends raw SQL, so it must never reach a browser or other untrusted client. Do not describe it as browser-compatible even though `fetch`-only code would technically run there; the correct pattern is an Edge Script (or `database-rest` behind an auth check) that holds the token and exposes only intended queries. D1-shaped surface: `connect()`, `prepare().bind()`, `.all()`/`.first()`/`.raw()`/`.run()`, plus `batch()` (one transaction, one round trip) and `exec()` (multi-statement script). Deliberately stateless: no baton tracking, no connection pool, no interactive transactions, no cursor streaming. Publishable to npm.
- **`@bunny.net/database-client`** (`packages/database-client/`) — Standalone SQL client for Bunny Database, for application code rather than the CLI. Speaks hrana-over-HTTP (`POST /v2/pipeline`) using only `fetch`, so it runs unchanged on Edge Scripting (Deno), Bun, and Node. Zero dependencies. **Server-side only, and documented as such:** an auth token is a bearer credential for the whole database and the client sends raw SQL, so it must never reach a browser or other untrusted client. Do not describe it as browser-compatible even though `fetch`-only code would technically run there; the correct pattern is an Edge Script (or `database-rest` behind an auth check) that holds the token and exposes only intended queries. Prepared-statement surface: `connect()`, `prepare().bind()`, `.all()`/`.first()`/`.raw()`/`.run()`, plus `batch()` (one transaction, one round trip) and `exec()` (multi-statement script). Deliberately stateless: no baton tracking, no connection pool, no interactive transactions, no cursor streaming. Publishable to npm.
- **`@bunny.net/database-shell`** (`packages/database-shell/`) — Standalone interactive SQL shell for libSQL databases. Framework-agnostic REPL, dot-commands, formatting, masking, and history. Also usable as a standalone CLI (binary: `bsql`).
- **`@bunny.net/scriptable-dns-types`** (`packages/scriptable-dns-types/`): Ambient TypeScript declarations for the Scriptable DNS runtime globals (`ARecord`, `Monitoring`, `RoutingEngine`, etc.). Types-only, no runtime code: the DNS runtime can't `import`, so these power editor autocomplete and an optional typecheck step. Scaffolded into projects by `bunny dns scripts init`; intended to also feed the dashboard editor. Publishable to npm.
- **`@bunny.net/sandbox`** (`packages/sandbox/`) — Standalone sandbox SDK. Code-first DX (`Sandbox.create`, `writeFiles`, `runCommand`, `exposePort`, `setEnv`/`getEnv`/`unsetEnv`, `listFiles`/`deleteFile`/`rename`/`exists`) over Magic Containers provisioning plus an `ssh2` SSH/SFTP transport. Blocking `runCommand` accepts `timeout` (rejects with `CommandTimeoutError` carrying partial output), `signal` for cancellation, and `onStdout`/`onStderr` callbacks for live output. Env vars can be baked in at `create` (persisted), passed per-command via `runCommand({ env })` (temporary), or persisted after creation via `setEnv`. The handle implements `Symbol.dispose`/`Symbol.asyncDispose` so `using`/`await using` release the SSH connection (without deleting the sandbox). Zero CLI dependencies.
Expand Down
6 changes: 3 additions & 3 deletions packages/database-client/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @bunny.net/database-client

A small SQL client for [Bunny Database](https://bunny.net). It uses `fetch` and nothing else, so the same code runs on Bunny Edge Scripting (Deno), Bun, and Node. No dependencies.
A small SQL client for [Bunny Database](https://bunny.net). It has no dependencies and uses `fetch` and nothing else, so the same code runs on Bunny Edge Scripting (Deno), Bun, and Node.

> [!WARNING]
> **Server-side only. Never ship this to a browser or any other untrusted client.**
Expand Down Expand Up @@ -67,7 +67,7 @@ Binds positional `?` parameters and returns a new statement. Accepts `null`, `bo

Anything else throws instead of being quietly converted, because SQLite has nowhere to put it. `Date` gets its own message suggesting `.toISOString()` or `.getTime()`, since guessing which one you meant would change what ends up in the column.

Integer `number`s past 2^53 are rejected rather than rounded: by the time the client sees one it has already lost precision, so pass a `bigint` for values that large. Bigints must fit SQLite's signed 64-bit range.
Integer `number`s past 2^53 also throw: JavaScript has already lost the precision by the time the client sees the value, so storing it would quietly write the wrong number. Pass a `bigint` for values that large. Bigints must fit SQLite's signed 64-bit range.

### Executing

Expand Down Expand Up @@ -244,7 +244,7 @@ BunnySDK.net.http.serve(async (request: Request): Promise<Response> => {
});
```

The browser calls your endpoint, and your endpoint decides what SQL runs. If you would rather not hand-write endpoints
The browser calls your endpoint, and your endpoint decides what SQL runs.

### Handling tokens

Expand Down
88 changes: 88 additions & 0 deletions packages/database-client/src/env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, test } from "bun:test";
import { readEnv } from "./env.ts";

const NAME = "BUNNY_READENV_TEST";

type GlobalWithSniffs = {
Deno?: { env?: { get(key: string): string | undefined } };
process?: unknown;
};
const g = globalThis as GlobalWithSniffs;

describe("readEnv", () => {
test("reads from process.env", () => {
process.env[NAME] = "from-process";
try {
expect(readEnv(NAME)).toBe("from-process");
} finally {
delete process.env[NAME];
}
});

test("missing and empty string both read as unset", () => {
delete process.env[NAME];
expect(readEnv(NAME)).toBeUndefined();
process.env[NAME] = "";
try {
expect(readEnv(NAME)).toBeUndefined();
} finally {
delete process.env[NAME];
}
});

test("prefers Deno.env.get when a Deno global is present", () => {
process.env[NAME] = "from-process";
g.Deno = { env: { get: () => "from-deno" } };
try {
expect(readEnv(NAME)).toBe("from-deno");
} finally {
delete g.Deno;
delete process.env[NAME];
}
});

test("an empty Deno value falls through to process.env", () => {
process.env[NAME] = "from-process";
g.Deno = { env: { get: () => "" } };
try {
expect(readEnv(NAME)).toBe("from-process");
} finally {
delete g.Deno;
delete process.env[NAME];
}
});

test("a permission throw from Deno.env.get reads as unset, not a crash", () => {
g.Deno = {
env: {
get: () => {
throw new Error("NotCapable: Requires env access");
},
},
};
try {
expect(readEnv(NAME)).toBeUndefined();
} finally {
delete g.Deno;
}
});

test("a permission throw from process.env reads as unset, not a crash", () => {
const realProcess = g.process;
g.process = {
env: new Proxy(
{},
{
get() {
throw new Error("NotCapable: Requires env access");
},
},
),
};
try {
expect(readEnv(NAME)).toBeUndefined();
} finally {
g.process = realProcess;
}
});
});
26 changes: 10 additions & 16 deletions packages/database-client/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
export const ENV_DATABASE_URL = "BUNNY_DATABASE_URL";
export const ENV_DATABASE_AUTH_TOKEN = "BUNNY_DATABASE_AUTH_TOKEN";

// Runtime sniff: either global may be absent (wrong runtime) or throw on read (Deno without --allow-env).
const g = globalThis as {
Deno?: { env?: { get(key: string): string | undefined } };
process?: { env?: Record<string, string | undefined> };
};

export function readEnv(name: string): string | undefined {
const deno = (
globalThis as { Deno?: { env?: { get(key: string): string | undefined } } }
).Deno;
if (deno?.env?.get) {
try {
const value = deno.env.get(name);
if (value) return value;
} catch {
// No env permission.
// Fall through to process.env, then to the caller's error.
}
try {
return g.Deno?.env?.get(name) || g.process?.env?.[name] || undefined;
} catch {
return undefined;
}

const proc = (
globalThis as { process?: { env?: Record<string, string | undefined> } }
).process;
return proc?.env?.[name] || undefined;
}
Loading