@rdlabo/workers-hono-kit 0.4.0 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ // `t_app` = wall time inside the Hono app (this middleware wrapping `next()`), i.e. everything from
2
+ // the middlewares/auth guards through the route handler. On a DB-round-trip-bound Worker it is
3
+ // dominated by database round trips, so it is the signal that exposes edge↔origin distance (Smart
4
+ // Placement) and serial-await fan-out.
5
+ //
6
+ // SCOPE DEPENDS ON WIRING. Whatever runs *inside* the app is included, whatever runs in `fetch`
7
+ // *before* the app is not. If the app builds its container/secrets/DB connection as an in-app
8
+ // middleware (e.g. secrets fetch + Hyperdrive connect inside `createApp`), those costs land in the
9
+ // cold `t_app`; if the container is built in `fetch` before `createApp(...).fetch(req)`, they do not.
10
+ // So the cold-row `t_app` is NOT directly comparable across repos wired differently — document the
11
+ // wiring per repo, or add a scope label, before comparing cold numbers.
12
+ //
13
+ // Note: on production Workers `Date.now()` only advances at I/O boundaries (Spectre mitigation), so
14
+ // `t_app` ≈ I/O wait, not CPU time — which is what you want for round-trip-bound analysis.
15
+ //
16
+ // Module scope: survives for the isolate's lifetime, so the first request after a cold start reports
17
+ // `cold: true` and later requests `cold: false`. Caveat: requests that arrive concurrently right after
18
+ // a cold start are labelled warm (only the very first flips the flag) even though they pay cold-init
19
+ // waits — a minor warm-side contamination, negligible at the low request rates this targets.
20
+ let isolateWarm = false;
21
+ /**
22
+ * Create a Hono middleware that records a per-request latency data point and emits it to Workers
23
+ * Logs (`console`) and/or Workers Analytics Engine (`dataset`).
24
+ *
25
+ * Register it first (`app.use('*', perfLog(...))`) so `t_app` covers the whole in-app path. Route
26
+ * grouping uses the matched route pattern (e.g. `/user/:id`) rather than the raw path so ids do not
27
+ * explode cardinality; unmatched requests collapse to `(unmatched)`. Colo comes from `request.cf.colo`;
28
+ * cold/warm from an isolate-scoped flag. See {@link PerfLogOptions} for the two sinks and the note
29
+ * above for exactly what `t_app` includes (it depends on where secrets/DB-connect are wired).
30
+ *
31
+ * Two wiring styles, both A/B-capable (Workers Logs and/or Analytics Engine):
32
+ *
33
+ * @example Bare, when the app is served with `app.fetch(req, env, ctx)` — reads `PERF` (Analytics
34
+ * Engine binding) and `PERF_LOG === '1'` (Workers Logs) straight off `c.env`:
35
+ * ```ts
36
+ * app.use('*', perfLog());
37
+ * ```
38
+ *
39
+ * @example Explicit, when the app is built without Hono `env` (e.g. `createApp(container).fetch(req)`)
40
+ * — thread the bindings in:
41
+ * ```ts
42
+ * app.use('*', perfLog({ console: env.PERF_LOG === '1', dataset: env.PERF }));
43
+ * ```
44
+ *
45
+ * @example Query Analytics Engine (SQL API), handler p50/p90 by route and colo:
46
+ * ```sql
47
+ * SELECT blob1 AS path, blob2 AS colo,
48
+ * quantileWeighted(0.5)(double1, _sample_interval) AS p50,
49
+ * quantileWeighted(0.9)(double1, _sample_interval) AS p90,
50
+ * sum(_sample_interval) AS n
51
+ * FROM your_dataset WHERE timestamp > now() - INTERVAL '7' DAY
52
+ * GROUP BY path, colo ORDER BY n DESC
53
+ * ```
54
+ */
55
+ export function perfLog(options = {}) {
56
+ const { console: toConsole, dataset, sampleRate = 1 } = options;
57
+ const rate = Math.min(1, Math.max(0, sampleRate)); // clamp so out-of-range values can't invert sampling
58
+ return async (c, next) => {
59
+ const cold = !isolateWarm;
60
+ isolateWarm = true;
61
+ const start = Date.now();
62
+ await next();
63
+ const tApp = Date.now() - start;
64
+ // Resolve sinks. Both are independently overridable: an explicit option wins, otherwise fall back
65
+ // to bindings on `c.env` (populated when the app is served with `app.fetch(req, env, ctx)`), so a
66
+ // bare `perfLog()` still works. `PERF` = Analytics Engine dataset binding; `PERF_LOG === '1'` turns
67
+ // on Workers Logs. `console: false` explicitly disables Workers Logs even when `PERF_LOG` is set.
68
+ const envBindings = c.env;
69
+ const sink = dataset ?? envBindings?.PERF;
70
+ const emitConsole = toConsole !== undefined ? toConsole : envBindings?.PERF_LOG === '1';
71
+ if (!sink && !emitConsole) {
72
+ return;
73
+ }
74
+ // Matched route pattern keeps cardinality low (`/user/:id`, not `/user/4821`). Use the deprecated
75
+ // `c.req.routePath` (not `routePath(c)` from `hono/route`) so the middleware works across the whole
76
+ // `hono` peer range `^4.6.0` — `hono/route` only exists on newer hono and would break the floor.
77
+ // Unmatched requests (404 / bot scans) collapse to a single label so they cannot explode cardinality.
78
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- routePath(c) needs hono ≥4.8; peer floor is 4.6
79
+ const matched = c.req.routePath;
80
+ const path = matched && matched !== '/*' ? matched : '(unmatched)';
81
+ const colo = c.req.raw.cf?.colo ?? '-';
82
+ const method = c.req.method;
83
+ const status = c.res.status;
84
+ // In-code sampling thins Analytics Engine writes only; Workers Logs volume is controlled separately
85
+ // by the observability `head_sampling_rate`. Low-traffic Workers should leave `sampleRate` at 1.
86
+ if (sink && (rate >= 1 || Math.random() < rate)) {
87
+ sink.writeDataPoint({
88
+ doubles: [tApp, cold ? 1 : 0, status],
89
+ blobs: [path, colo, method],
90
+ indexes: [path],
91
+ });
92
+ }
93
+ if (emitConsole) {
94
+ console.log(JSON.stringify({ perf: { cold, colo, method, path, status, t_app: tApp } }));
95
+ }
96
+ };
97
+ }
@@ -13,3 +13,5 @@ export type { Database, DisposableDatabase, QueryRunner, TxOf } from '../db/data
13
13
  export { authHeaders, registerFirebaseToken, provisionUser } from './auth.js';
14
14
  export { configurableFake } from './configurable-fake.js';
15
15
  export { fakeApiList, fakePaymentIntent, fakeStripeEvent, fakeCheckoutSession, fakeCustomer, fakePrice, fakeSubscription, } from './stripe-fixtures.js';
16
+ export { fakeKv, fakeQueue } from './workers-bindings.js';
17
+ export type { FakeQueue } from './workers-bindings.js';
@@ -13,3 +13,5 @@ export { authHeaders, registerFirebaseToken, provisionUser } from './auth.js';
13
13
  export { configurableFake } from './configurable-fake.js';
14
14
  // Test fixture factories for Stripe objects.
15
15
  export { fakeApiList, fakePaymentIntent, fakeStripeEvent, fakeCheckoutSession, fakeCustomer, fakePrice, fakeSubscription, } from './stripe-fixtures.js';
16
+ // In-memory Workers binding fakes (KV / Queues producer).
17
+ export { fakeKv, fakeQueue } from './workers-bindings.js';
@@ -0,0 +1,49 @@
1
+ import type { KVNamespace } from '../cache/kv-cache.js';
2
+ import type { QueueLike } from '../queue/send.js';
3
+ /**
4
+ * In-memory {@link QueueLike} test double that records every enqueued message.
5
+ *
6
+ * @remarks
7
+ * `sent` collects all message bodies (from both {@link FakeQueue.send} and
8
+ * {@link FakeQueue.sendBatch}). `batchCount` increments once per `sendBatch` call so tests can
9
+ * assert producers bound subrequests to `ceil(N / chunkSize)` rather than `N`.
10
+ *
11
+ * @typeParam Body - Message body type.
12
+ */
13
+ export interface FakeQueue<Body = unknown> extends QueueLike<Body> {
14
+ /** Every body passed to `send` or `sendBatch`, in enqueue order. */
15
+ readonly sent: Body[];
16
+ /** Number of `sendBatch` calls issued. */
17
+ readonly batchCount: number;
18
+ /**
19
+ * Enqueue a single message (one subrequest in production).
20
+ *
21
+ * @param body - Message payload.
22
+ */
23
+ send(body: Body): Promise<void>;
24
+ }
25
+ /**
26
+ * Create an in-memory {@link FakeQueue} for offline producer tests.
27
+ *
28
+ * @typeParam Body - Message body type.
29
+ * @returns A queue double assignable to `QueueLike` / Workers `Queue` bindings in tests.
30
+ * @example
31
+ * ```ts
32
+ * const queue = fakeQueue<{ userId: number }>();
33
+ * await sendInChunks(queue, [1, 2, 3]);
34
+ * expect(queue.batchCount).toBe(1);
35
+ * expect(queue.sent).toEqual([1, 2, 3]);
36
+ * ```
37
+ */
38
+ export declare function fakeQueue<Body = unknown>(): FakeQueue<Body>;
39
+ /**
40
+ * Create a minimal in-memory {@link KVNamespace} for offline tests (`KVCache`, env fixtures, etc.).
41
+ *
42
+ * @remarks
43
+ * Only `get` / `put` / `delete` are fully implemented (the subset {@link KVCache} uses). `list` and
44
+ * `getWithMetadata` return empty/null stubs so the object is structurally assignable to Workers
45
+ * `KVNamespace` when tests need a binding-shaped fake env.
46
+ *
47
+ * @returns An in-memory KV double.
48
+ */
49
+ export declare function fakeKv(): KVNamespace;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Create an in-memory {@link FakeQueue} for offline producer tests.
3
+ *
4
+ * @typeParam Body - Message body type.
5
+ * @returns A queue double assignable to `QueueLike` / Workers `Queue` bindings in tests.
6
+ * @example
7
+ * ```ts
8
+ * const queue = fakeQueue<{ userId: number }>();
9
+ * await sendInChunks(queue, [1, 2, 3]);
10
+ * expect(queue.batchCount).toBe(1);
11
+ * expect(queue.sent).toEqual([1, 2, 3]);
12
+ * ```
13
+ */
14
+ export function fakeQueue() {
15
+ const sent = [];
16
+ let batchCount = 0;
17
+ return {
18
+ get sent() {
19
+ return sent;
20
+ },
21
+ get batchCount() {
22
+ return batchCount;
23
+ },
24
+ send(body) {
25
+ sent.push(body);
26
+ return Promise.resolve();
27
+ },
28
+ sendBatch(messages) {
29
+ batchCount++;
30
+ for (const m of messages) {
31
+ sent.push(m.body);
32
+ }
33
+ return Promise.resolve();
34
+ },
35
+ };
36
+ }
37
+ /**
38
+ * Create a minimal in-memory {@link KVNamespace} for offline tests (`KVCache`, env fixtures, etc.).
39
+ *
40
+ * @remarks
41
+ * Only `get` / `put` / `delete` are fully implemented (the subset {@link KVCache} uses). `list` and
42
+ * `getWithMetadata` return empty/null stubs so the object is structurally assignable to Workers
43
+ * `KVNamespace` when tests need a binding-shaped fake env.
44
+ *
45
+ * @returns An in-memory KV double.
46
+ */
47
+ export function fakeKv() {
48
+ const store = new Map();
49
+ return {
50
+ get: (key) => Promise.resolve(store.get(key) ?? null),
51
+ put: (key, value) => {
52
+ store.set(key, value);
53
+ return Promise.resolve();
54
+ },
55
+ delete: (key) => {
56
+ store.delete(key);
57
+ return Promise.resolve();
58
+ },
59
+ list: () => Promise.resolve({ keys: [], list_complete: true, cacheStatus: null }),
60
+ getWithMetadata: () => Promise.resolve({ value: null, metadata: null, cacheStatus: null }),
61
+ };
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rdlabo/workers-hono-kit",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -2,14 +2,15 @@
2
2
  // Record the Drizzle baseline (0000) as *already applied* on an existing (brownfield) MySQL DB,
3
3
  // without executing its CREATE TABLE statements. One-time per environment.
4
4
  //
5
- // なぜ: 現行サービスの DB は先にスキーマが在るため、introspect 由来の baseline 0000 を `db:migrate`
6
- // で流すと衝突する。代わりに `__drizzle_migrations` marker 1 行入れ、以後 `db:migrate` when の
7
- // 大きい 0001+ だけを適用するようにする(新規/テスト DB marker 無しでフルチェーン=挙動不変)。
5
+ // Why: an in-production DB already has its schema, so running the introspect-derived baseline 0000 via
6
+ // `db:migrate` collides. Instead we insert a single marker row into `__drizzle_migrations` so that
7
+ // subsequent `db:migrate` runs apply only the later 0001+ (larger `when`). A fresh / test DB has no
8
+ // marker, so the full chain runs (behavior unchanged).
8
9
  //
9
- // 実行基盤: VPC 内(AWS CodeBuild 等)から RDS へ直 TCP。Hyperdrive Workers 専用で使えない。
10
- // creds envCodeBuild では Secrets Manager → env に注入):
10
+ // Where it runs: direct TCP from inside a VPC (e.g. AWS CodeBuild) to RDS. Hyperdrive is Workers-only
11
+ // and cannot be used here. Credentials come from env (on CodeBuild, Secrets Manager → injected into env):
11
12
  // DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_NAME
12
- // migrations フォルダ: 既定 ./drizzle(--migrations <dir> または MIGRATIONS_DIR で上書き)。
13
+ // Migrations folder: defaults to ./drizzle (override with --migrations <dir> or MIGRATIONS_DIR).
13
14
  //
14
15
  // usage:
15
16
  // npx workers-hono-kit-db-baseline [--migrations ./drizzle]
@@ -23,9 +24,9 @@ function arg(name) {
23
24
  }
24
25
 
25
26
  const migrationsFolder = arg('migrations') ?? process.env.MIGRATIONS_DIR ?? './drizzle';
26
- // db:migratehonoDrizzleConfig)と同じ DB_SECRET 解釈を共有する。CI/本番は AWS Secrets Manager の
27
- // RDS マネージド secret DB_SECRET に渡す運用(不正/欠損は resolveDbSecret throw)。未設定時は
28
- // 従来の個別 DB_* env にフォールバック。
27
+ // Shares the same DB_SECRET handling as db:migrate (honoDrizzleConfig). CI/production passes an AWS
28
+ // Secrets Manager RDS managed secret via DB_SECRET (invalid/missing resolveDbSecret throws). When
29
+ // unset, it falls back to the individual DB_* env vars.
29
30
  const secret = resolveDbSecret();
30
31
  const conn = secret
31
32
  ? {