跳到內容

ch28-hyperdrive

取得並執行

這個範例可以獨立 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

說明

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.

Terminal window
# as an unprivileged user
initdb -D /tmp/pgdata -U postgres --auth=trust
pg_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');
SQL

The password is not optional even with trust auth — see below.

Terminal window
npm install
npx wrangler types --env-interface CloudflareBindings
npx wrangler dev --port 9035
RouteWhat it shows
/bindingThe Hyperdrive object’s real shape. Note the synthetic *.hyperdrive.local host.
/pgnode-postgres against the real database.
/postgres-jspostgres.js, with the three options Cloudflare documents.
/mysql2That mysql2/promise imports fine, and that eval throws — which is why disableEval: true is required.
/raw-testWrite then read through both the cached and uncached bindings.
/compare-bindingsThat nothing distinguishes a cached binding from an uncached one at runtime.
/bench?n=4Connect vs query cost.
/txA real interactive transaction: begin → update → read → rollback → read.

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'
}

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.

Terminal window
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”
Terminal window
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.”

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.

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 SELECT can return the cached result until the configured max_age expires.”

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:

Terminal window
npx wrangler hyperdrive create linkforge-fresh --connection-string="..." --caching-disabled

and 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.

Cloudflare’s MySQL examples pass disableEval: true with the comment “Required to enable mysql2 compatibility for Workers” — and never explain it.

Terminal window
curl -s localhost:9035/mysql2 | jq
# imports: { hasCreateConnection: "function" }
# evalAvailable: EvalError: Code generation from strings disallowed for this context

mysql2 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"]
}