ch28-hyperdrive
對應 28. Hyperdrive:接上你既有的 Postgres / MySQL
在 GitHub 上檢視·4 個檔案·9.4 KB
取得並執行
這個範例可以獨立 clone 執行,不依賴其他章節。
npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch28-hyperdrive
cd ch28-hyperdrive
npm install可用指令
npm run dev # wrangler dev
npm run deploy # wrangler deploy
npm run types # wrangler types --env-interface CloudflareBindings說明
ch28 — Hyperdrive
Section titled “ch28 — Hyperdrive”Probe project for chapter 28, running against a real local PostgreSQL 16, not a mock.
Verified with wrangler 4.118.0, PostgreSQL 16.13, pg 8.16.3, postgres.js 3.4.7,
mysql2 3.15.3, compatibility_date 2026-07-24, nodejs_compat.
Set up a local Postgres
Section titled “Set up a local Postgres”# as an unprivileged userinitdb -D /tmp/pgdata -U postgres --auth=trustpg_ctl -D /tmp/pgdata -o '-p 5433 -h 127.0.0.1' -l /tmp/pg.log start
psql -h 127.0.0.1 -p 5433 -U postgres -c "alter user postgres with password 'localpw';"psql -h 127.0.0.1 -p 5433 -U postgres -c "create database linkforge;"psql -h 127.0.0.1 -p 5433 -U postgres -d linkforge <<'SQL'create table links ( slug text primary key, tenant_id text not null, url text not null, clicks integer not null default 0, created_at timestamptz not null default now());create index links_tenant on links (tenant_id, created_at desc);insert into links (slug, tenant_id, url) values ('a','acme','https://example.com/a'), ('b','acme','https://example.com/b'), ('c','other','https://example.com/c');SQLThe password is not optional even with trust auth — see below.
npm installnpx wrangler types --env-interface CloudflareBindingsnpx wrangler dev --port 9035Routes
Section titled “Routes”| Route | What it shows |
|---|---|
/binding | The Hyperdrive object’s real shape. Note the synthetic *.hyperdrive.local host. |
/pg | node-postgres against the real database. |
/postgres-js | postgres.js, with the three options Cloudflare documents. |
/mysql2 | That mysql2/promise imports fine, and that eval throws — which is why disableEval: true is required. |
/raw-test | Write then read through both the cached and uncached bindings. |
/compare-bindings | That nothing distinguishes a cached binding from an uncached one at runtime. |
/bench?n=4 | Connect vs query cost. |
/tx | A real interactive transaction: begin → update → read → rollback → read. |
localConnectionString requires a password
Section titled “localConnectionString requires a password”Without one, wrangler dev refuses to start:
✘ [ERROR] Unexpected options passed to `new Miniflare()` constructor: hyperdrives: { HYPERDRIVE: 'postgres://postgres@127.0.0.1:5433/linkforge', ^ You must provide a password - e.g. 'user:password@database.example.com:port/databasename' }The env var was renamed
Section titled “The env var was renamed”From wrangler 4.118.0’s own source (applyHyperdriveEnvVars):
const prefix = `CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_`;const deprecatedPrefix = `WRANGLER_HYPERDRIVE_LOCAL_CONNECTION_STRING_`;Both still resolve, but WRANGLER_-prefixed is literally named
deprecatedPrefix in the implementation. Most 2025-era tutorials use it.
The binding lies about the host
Section titled “The binding lies about the host”curl -s localhost:9035/binding | jq .fields{ "host": "99df8f500677382d02858007fb2d785f.hyperdrive.local", "port": 5432, ... }The localConnectionString points at 127.0.0.1:5433, but the binding reports
a synthetic host on 5432 — wrangler inserts a shim locally too. You cannot use
the host to tell local from production.
Interactive transactions work — unlike D1
Section titled “Interactive transactions work — unlike D1”curl -s localhost:9035/tx | jq# { "insideTx": 1, "afterRollback": 0 }Chapter 10 measured D1’s db.transaction() throwing Failed query: begin.
Hyperdrive gives you the real thing. If your logic needs read-decide-write
atomicity, that is the reason to pick Hyperdrive over D1.
Connection lifecycle: end() is no longer needed
Section titled “Connection lifecycle: end() is no longer needed”The current connection-lifecycle docs say you do not need client.end() —
cleanup happens when the invocation ends. The widely-copied
ctx.waitUntil(client.end()) is superseded (documented as unnecessary, not as
harmful). This example uses the current pattern.
What is still required is the location: create the client inside the handler. Cloudflare’s own code comment for the global-scope version reads ”🔴 Bad: … this client becomes stale and subsequent queries will throw hard errors.”
prepare: true, not false
Section titled “prepare: true, not false”Hyperdrive uses transaction-mode pooling, so pgbouncer habits say to disable prepared statements. Cloudflare says the opposite: “Hyperdrive will not cache prepared statements when this option is set to false.” Keep the default.
The cache is not invalidated by writes
Section titled “The cache is not invalidated by writes”Verbatim from the query-caching page:
“Hyperdrive does not purge or invalidate cached read query results when your application writes to your database. A later matching
SELECTcan return the cached result until the configuredmax_ageexpires.”
Defaults: max_age 60s, stale_while_revalidate 15s, both on by default. So a
row a user just created can be missing from their list view for up to 75
seconds.
The documented fix is a second config:
npx wrangler hyperdrive create linkforge-fresh --connection-string="..." --caching-disabledand two bindings, routed per query path. There is no per-query bypass API. The docs even have a section titled “Do not use SQL comments as cache controls”.
None of this is observable locally. /raw-test returns rowCount: 1 from
both bindings here, because with localConnectionString “Hyperdrive’s
connection pooling and query caching do not take effect” — your Worker connects
straight to the database. And /compare-bindings shows nothing on the binding
object reveals whether caching is on. Test read-after-write paths with
wrangler dev --remote.
disableEval: true for mysql2, and why
Section titled “disableEval: true for mysql2, and why”Cloudflare’s MySQL examples pass disableEval: true with the comment
“Required to enable mysql2 compatibility for Workers” — and never explain it.
curl -s localhost:9035/mysql2 | jq# imports: { hasCreateConnection: "function" }# evalAvailable: EvalError: Code generation from strings disallowed for this contextmysql2 compiles row parsers with eval(). Workers forbids it (chapter 27).
Without the flag, createConnection() succeeds and the first query()
throws — the failure lands somewhere you would not look.
Note also: Cloudflare’s MySQL examples only ever demonstrate the discrete
fields (host/user/password/database/port). Searching the MySQL pages
for connectionString returns nothing — so the docs are silent, not
prohibitive. Follow the examples, but don’t repeat the common claim that
connectionString is forbidden for MySQL; no such statement exists.
原始碼
wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ch28-hyperdrive",
"main": "src/index.ts",
"compatibility_date": "2026-07-24",
// REQUIRED. Every Postgres/MySQL driver needs node:net / node:tls.
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
"hyperdrive": [
{
"binding": "HYPERDRIVE",
"id": "0000000000000000000000000000cafe",
// Without this, `wrangler dev` cannot connect to anything locally.
"localConnectionString": "postgres://postgres:localpw@127.0.0.1:5433/linkforge"
},
{
// The read-after-write path needs a SECOND config created with
// --caching-disabled. The cache is NOT invalidated by writes.
"binding": "HYPERDRIVE_NOCACHE",
"id": "0000000000000000000000000000beef",
"localConnectionString": "postgres://postgres:localpw@127.0.0.1:5433/linkforge"
}
]
}package.json
{
"name": "ch28-hyperdrive",
"private": true,
"type": "module",
"scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", "types": "wrangler types --env-interface CloudflareBindings" },
"dependencies": { "pg": "^8.16.3", "postgres": "^3.4.7", "mysql2": "^3.15.3", "drizzle-orm": "^0.45.2" },
"devDependencies": { "wrangler": "^4.118.0", "typescript": "^5.9.2", "@types/pg": "^8.15.5", "@types/node": "^22.0.0" }
}src/index.ts
import { Client } from "pg";
import postgres from "postgres";
const t = async (fn: () => unknown): Promise<Record<string, unknown>> => {
const started = Date.now();
try {
const v = await fn();
return { ok: v === undefined ? "(undefined)" : v, ms: Date.now() - started };
} catch (e) {
return {
threwName: (e as Error).name,
threw: String((e as Error).message ?? e).slice(0, 260),
ms: Date.now() - started,
};
}
};
export default {
async fetch(request: Request, env: CloudflareBindings, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const q = url.searchParams;
const hd = env.HYPERDRIVE;
switch (url.pathname) {
// What is actually on the binding?
case "/binding": {
return Response.json({
typeofBinding: typeof hd,
ownKeys: Object.getOwnPropertyNames(hd),
protoKeys: Object.getOwnPropertyNames(Object.getPrototypeOf(hd)),
ctorName: Object.getPrototypeOf(hd)?.constructor?.name ?? null,
// Discrete fields. Every MySQL example in the docs uses these; the
// docs never state that connectionString is unsupported for MySQL.
fields: {
host: hd.host,
port: hd.port,
user: hd.user,
database: hd.database,
passwordLength: hd.password?.length ?? null,
},
// Never log this in real code.
connectionStringShape: hd.connectionString.replace(/\/\/[^@]*@/, "//<redacted>@"),
});
}
// node-postgres. One Client per request, created INSIDE the handler.
case "/pg": {
return Response.json(
await t(async () => {
const client = new Client({ connectionString: hd.connectionString });
await client.connect();
const res = await client.query<{ slug: string; url: string }>(
"select slug, url from links where tenant_id = $1 order by created_at desc",
[q.get("tenant") ?? "acme"],
);
// NOTE: no client.end(). The current connection-lifecycle docs say
// "you do NOT need to call client.end() ... to clean up database
// clients" -- cleanup happens when the invocation ends. The
// ctx.waitUntil(client.end()) idiom copied from older docs and most
// blog posts is superseded, not harmful.
return { rowCount: res.rowCount, rows: res.rows };
}),
);
}
// postgres.js. Note `fetch_types: false` -- see the README.
case "/postgres-js": {
return Response.json(
await t(async () => {
const sql = postgres(hd.connectionString, {
max: 5, // Workers limits concurrent external connections
fetch_types: false, // skip an extra round trip when you have no array types
prepare: true, // DEFAULT, and Cloudflare's recommendation:
// "Hyperdrive will not cache prepared statements
// when this option is set to false."
// This is the OPPOSITE of the usual pgbouncer advice.
});
const rows = await sql`select slug, url from links where tenant_id = ${
q.get("tenant") ?? "acme"
}`;
return { rowCount: rows.length, rows: [...rows] };
}),
);
}
// mysql2 needs `disableEval: true` because Workers forbids eval().
// We cannot point it at Postgres, so this route only proves the shape of
// the failure you get without the flag.
case "/mysql2": {
const out: Record<string, unknown> = {};
out.imports = await t(async () => {
const m = await import("mysql2/promise");
return { hasCreateConnection: typeof m.createConnection };
});
out.evalAvailable = await t(() => (0, eval)("1 + 1"));
out.note =
"mysql2 compiles row parsers with eval() unless you pass disableEval: true. Workers forbids eval, so without the flag the FIRST query throws, not the connect.";
return Response.json(out);
}
// Read-after-write. In production the cached binding can serve a stale
// row for up to max_age (default 60s) + stale_while_revalidate (15s).
case "/raw-test": {
const slug = `probe-${crypto.randomUUID().slice(0, 8)}`;
const write = new Client({ connectionString: hd.connectionString });
await write.connect();
await write.query("insert into links (slug, tenant_id, url) values ($1,$2,$3)", [
slug,
"probe",
"https://example.com/probe",
]);
const read = async (binding: Hyperdrive, label: string) => {
const c = new Client({ connectionString: binding.connectionString });
await c.connect();
const r = await c.query("select slug from links where slug = $1", [slug]);
return { [label]: r.rowCount };
};
return Response.json({
slug,
note: "Locally BOTH bindings hit the same Postgres directly -- Hyperdrive's cache does not exist in wrangler dev. In production the cached binding may return 0 here.",
cached: await t(() => read(env.HYPERDRIVE, "rowCount")),
uncached: await t(() => read(env.HYPERDRIVE_NOCACHE, "rowCount")),
});
}
// Are the two bindings actually distinguishable at runtime?
case "/compare-bindings": {
return Response.json({
cached: {
host: env.HYPERDRIVE.host,
database: env.HYPERDRIVE.database,
same: env.HYPERDRIVE.connectionString === env.HYPERDRIVE_NOCACHE.connectionString,
},
note: "Nothing on the binding tells you whether caching is on. That is configured server-side when you create the Hyperdrive config, and is invisible to your code.",
});
}
// How expensive is connecting?
case "/bench": {
const n = Number(q.get("n") ?? 3);
const runs: unknown[] = [];
for (let i = 0; i < n; i++) {
runs.push(
await t(async () => {
const c = new Client({ connectionString: hd.connectionString });
const t0 = Date.now();
await c.connect();
const connected = Date.now() - t0;
const t1 = Date.now();
await c.query("select 1");
const queried = Date.now() - t1;
return { connectMs: connected, queryMs: queried };
}),
);
}
return Response.json({ runs });
}
// Transactions work -- unlike D1 (chapter 9/10).
case "/tx": {
return Response.json(
await t(async () => {
const c = new Client({ connectionString: hd.connectionString });
await c.connect();
const out: Record<string, unknown> = {};
try {
await c.query("begin");
await c.query("update links set clicks = clicks + 1 where slug = 'a'");
const mid = await c.query<{ clicks: number }>(
"select clicks from links where slug = 'a'",
);
out.insideTx = mid.rows[0]?.clicks;
await c.query("rollback");
const after = await c.query<{ clicks: number }>(
"select clicks from links where slug = 'a'",
);
out.afterRollback = after.rows[0]?.clicks;
} finally {
/* nothing to close -- see the note in /pg */
}
return out;
}),
);
}
default:
return new Response(
"/binding /pg /postgres-js /mysql2 /raw-test /compare-bindings /bench?n= /tx\n",
{ status: 404 },
);
}
},
} satisfies ExportedHandler<CloudflareBindings>;tsconfig.json
{
"compilerOptions": {
"target": "esnext", "lib": ["esnext"], "module": "esnext",
"moduleResolution": "bundler", "types": ["./worker-configuration.d.ts", "node"],
"strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true
},
"include": ["src/**/*.ts", "worker-configuration.d.ts"]
}