@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.
- package/README.md +115 -24
- package/dist/business-time/index.d.ts +98 -21
- package/dist/business-time/index.js +99 -22
- package/dist/business-time/types.d.ts +3 -3
- package/dist/business-time/types.js +1 -1
- package/dist/db/columns.d.ts +8 -6
- package/dist/db/columns.js +25 -19
- package/dist/db/decimal.d.ts +17 -9
- package/dist/db/decimal.js +17 -9
- package/dist/db/jst.d.ts +11 -6
- package/dist/db/jst.js +11 -6
- package/dist/db/migrate.d.ts +21 -21
- package/dist/db/migrate.js +34 -29
- package/dist/db/orm-config.d.ts +10 -6
- package/dist/db/orm-config.js +15 -11
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/middleware/perf-log.d.ts +77 -0
- package/dist/middleware/perf-log.js +97 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/workers-bindings.d.ts +49 -0
- package/dist/testing/workers-bindings.js +62 -0
- package/package.json +1 -1
- package/scripts/db-baseline.mjs +10 -9
|
@@ -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
|
+
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/testing/index.js
CHANGED
|
@@ -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
package/scripts/db-baseline.mjs
CHANGED
|
@@ -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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
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
|
-
//
|
|
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:migrate
|
|
27
|
-
// RDS
|
|
28
|
-
//
|
|
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
|
? {
|