@shaferllc/keel 0.36.0 → 0.58.0
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 +14 -2
- package/dist/core/application.d.ts +58 -2
- package/dist/core/application.js +99 -3
- package/dist/core/authorization.d.ts +52 -0
- package/dist/core/authorization.js +97 -0
- package/dist/core/broadcasting.d.ts +49 -0
- package/dist/core/broadcasting.js +84 -0
- package/dist/core/broker.d.ts +398 -0
- package/dist/core/broker.js +602 -0
- package/dist/core/crypto.d.ts +1 -1
- package/dist/core/crypto.js +12 -3
- package/dist/core/database.d.ts +26 -1
- package/dist/core/database.js +65 -2
- package/dist/core/decorators.d.ts +39 -0
- package/dist/core/decorators.js +72 -0
- package/dist/core/exceptions.d.ts +77 -6
- package/dist/core/exceptions.js +168 -10
- package/dist/core/helpers.d.ts +6 -0
- package/dist/core/helpers.js +12 -0
- package/dist/core/http/kernel.js +14 -2
- package/dist/core/http/router.d.ts +14 -0
- package/dist/core/http/router.js +30 -1
- package/dist/core/index.d.ts +28 -6
- package/dist/core/index.js +15 -3
- package/dist/core/logger.d.ts +5 -0
- package/dist/core/logger.js +24 -2
- package/dist/core/migrations.js +3 -3
- package/dist/core/model.d.ts +19 -1
- package/dist/core/model.js +72 -4
- package/dist/core/provider.d.ts +13 -4
- package/dist/core/provider.js +12 -2
- package/dist/core/redis.d.ts +78 -0
- package/dist/core/redis.js +176 -0
- package/dist/core/request-logger.d.ts +26 -0
- package/dist/core/request-logger.js +48 -0
- package/dist/core/request.d.ts +17 -1
- package/dist/core/request.js +27 -1
- package/dist/core/scheduler.d.ts +60 -0
- package/dist/core/scheduler.js +166 -0
- package/dist/core/session.js +17 -2
- package/dist/core/storage.d.ts +57 -0
- package/dist/core/storage.js +98 -0
- package/dist/core/template.d.ts +50 -0
- package/dist/core/template.js +753 -0
- package/dist/core/testing.d.ts +54 -0
- package/dist/core/testing.js +141 -0
- package/dist/core/transformer.d.ts +89 -0
- package/dist/core/transformer.js +152 -0
- package/dist/core/validation.d.ts +20 -0
- package/dist/core/validation.js +52 -1
- package/dist/core/vite.d.ts +117 -0
- package/dist/core/vite.js +258 -0
- package/dist/vite/index.d.ts +40 -0
- package/dist/vite/index.js +146 -0
- package/package.json +16 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis integration. Like the database and mail layers, it's built on a small
|
|
3
|
+
* pluggable driver (`RedisConnection`) rather than a hard dependency — so the
|
|
4
|
+
* core imports no client and runs on Node and the edge. Point it at Upstash
|
|
5
|
+
* (HTTP/`fetch`), ioredis, node-redis, or the built-in `MemoryRedis` for tests.
|
|
6
|
+
*
|
|
7
|
+
* setRedis(new MemoryRedis()); // or an Upstash/ioredis adapter
|
|
8
|
+
* await redis().set("views", "1");
|
|
9
|
+
* await redis().incr("views"); // 2
|
|
10
|
+
* await redis().remember("user:1", 60, () => fetchUser(1));
|
|
11
|
+
*
|
|
12
|
+
* `Redis` adds JSON helpers and a `remember` cache pattern over the raw command
|
|
13
|
+
* seam; `redisStore()` exposes it as a `CacheStore` so the cache can be
|
|
14
|
+
* Redis-backed.
|
|
15
|
+
*/
|
|
16
|
+
/* ------------------------------ memory driver ----------------------------- */
|
|
17
|
+
/** An in-memory `RedisConnection` with TTL support — the default; ideal for tests. */
|
|
18
|
+
export class MemoryRedis {
|
|
19
|
+
store = new Map();
|
|
20
|
+
live(key) {
|
|
21
|
+
const entry = this.store.get(key);
|
|
22
|
+
if (!entry)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (entry.expires && entry.expires <= Date.now()) {
|
|
25
|
+
this.store.delete(key);
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
return entry;
|
|
29
|
+
}
|
|
30
|
+
async get(key) {
|
|
31
|
+
return this.live(key)?.value ?? null;
|
|
32
|
+
}
|
|
33
|
+
async set(key, value, options) {
|
|
34
|
+
const ms = options?.px ?? (options?.ex != null ? options.ex * 1000 : 0);
|
|
35
|
+
this.store.set(key, { value, expires: ms ? Date.now() + ms : 0 });
|
|
36
|
+
}
|
|
37
|
+
async del(...keys) {
|
|
38
|
+
let n = 0;
|
|
39
|
+
for (const key of keys)
|
|
40
|
+
if (this.store.delete(key))
|
|
41
|
+
n++;
|
|
42
|
+
return n;
|
|
43
|
+
}
|
|
44
|
+
async exists(...keys) {
|
|
45
|
+
let n = 0;
|
|
46
|
+
for (const key of keys)
|
|
47
|
+
if (this.live(key))
|
|
48
|
+
n++;
|
|
49
|
+
return n;
|
|
50
|
+
}
|
|
51
|
+
async incrBy(key, amount) {
|
|
52
|
+
const current = Number(this.live(key)?.value ?? 0);
|
|
53
|
+
const next = current + amount;
|
|
54
|
+
const expires = this.store.get(key)?.expires ?? 0;
|
|
55
|
+
this.store.set(key, { value: String(next), expires });
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
async expire(key, seconds) {
|
|
59
|
+
const entry = this.live(key);
|
|
60
|
+
if (!entry)
|
|
61
|
+
return false;
|
|
62
|
+
entry.expires = Date.now() + seconds * 1000;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
async ttl(key) {
|
|
66
|
+
const entry = this.live(key);
|
|
67
|
+
if (!entry)
|
|
68
|
+
return -2; // no such key
|
|
69
|
+
if (!entry.expires)
|
|
70
|
+
return -1; // no expiry
|
|
71
|
+
return Math.ceil((entry.expires - Date.now()) / 1000);
|
|
72
|
+
}
|
|
73
|
+
async keys(pattern) {
|
|
74
|
+
// Glob-ish: translate `*` and `?` to a regex.
|
|
75
|
+
const rx = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const key of this.store.keys())
|
|
78
|
+
if (this.live(key) && rx.test(key))
|
|
79
|
+
out.push(key);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
async flushAll() {
|
|
83
|
+
this.store.clear();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/* -------------------------------- the client ------------------------------ */
|
|
87
|
+
export class Redis {
|
|
88
|
+
conn;
|
|
89
|
+
constructor(conn) {
|
|
90
|
+
this.conn = conn;
|
|
91
|
+
}
|
|
92
|
+
get(key) {
|
|
93
|
+
return this.conn.get(key);
|
|
94
|
+
}
|
|
95
|
+
set(key, value, options) {
|
|
96
|
+
return this.conn.set(key, value, options);
|
|
97
|
+
}
|
|
98
|
+
del(...keys) {
|
|
99
|
+
return this.conn.del(...keys);
|
|
100
|
+
}
|
|
101
|
+
exists(...keys) {
|
|
102
|
+
return this.conn.exists(...keys);
|
|
103
|
+
}
|
|
104
|
+
async has(key) {
|
|
105
|
+
return (await this.conn.exists(key)) > 0;
|
|
106
|
+
}
|
|
107
|
+
incr(key) {
|
|
108
|
+
return this.conn.incrBy(key, 1);
|
|
109
|
+
}
|
|
110
|
+
decr(key) {
|
|
111
|
+
return this.conn.incrBy(key, -1);
|
|
112
|
+
}
|
|
113
|
+
incrBy(key, amount) {
|
|
114
|
+
return this.conn.incrBy(key, amount);
|
|
115
|
+
}
|
|
116
|
+
expire(key, seconds) {
|
|
117
|
+
return this.conn.expire(key, seconds);
|
|
118
|
+
}
|
|
119
|
+
ttl(key) {
|
|
120
|
+
return this.conn.ttl(key);
|
|
121
|
+
}
|
|
122
|
+
keys(pattern = "*") {
|
|
123
|
+
return this.conn.keys(pattern);
|
|
124
|
+
}
|
|
125
|
+
flushAll() {
|
|
126
|
+
return this.conn.flushAll();
|
|
127
|
+
}
|
|
128
|
+
/** Get a JSON value (parsed), or null if the key is unset. */
|
|
129
|
+
async getJson(key) {
|
|
130
|
+
const raw = await this.conn.get(key);
|
|
131
|
+
return raw == null ? null : JSON.parse(raw);
|
|
132
|
+
}
|
|
133
|
+
/** Set a value as JSON. */
|
|
134
|
+
setJson(key, value, options) {
|
|
135
|
+
return this.conn.set(key, JSON.stringify(value), options);
|
|
136
|
+
}
|
|
137
|
+
/** Return the cached JSON value, or compute it, store it for `seconds`, and return it. */
|
|
138
|
+
async remember(key, seconds, factory) {
|
|
139
|
+
const hit = await this.getJson(key);
|
|
140
|
+
if (hit !== null)
|
|
141
|
+
return hit;
|
|
142
|
+
const fresh = await factory();
|
|
143
|
+
await this.setJson(key, fresh, { ex: seconds });
|
|
144
|
+
return fresh;
|
|
145
|
+
}
|
|
146
|
+
/** The underlying driver, for raw access. */
|
|
147
|
+
get connection() {
|
|
148
|
+
return this.conn;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/* ----------------------------- cache adapter ------------------------------ */
|
|
152
|
+
/** Expose a `Redis` client as a `CacheStore`, so the cache can be Redis-backed. */
|
|
153
|
+
export function redisStore(client = redis()) {
|
|
154
|
+
return {
|
|
155
|
+
// The cache treats `undefined` as "miss"; Redis has no such value, so map it.
|
|
156
|
+
get: async (key) => (await client.getJson(key)) ?? undefined,
|
|
157
|
+
set: async (key, value, ttlMs) => {
|
|
158
|
+
await client.setJson(key, value, ttlMs ? { px: ttlMs } : undefined);
|
|
159
|
+
},
|
|
160
|
+
delete: async (key) => {
|
|
161
|
+
await client.del(key);
|
|
162
|
+
},
|
|
163
|
+
clear: () => client.flushAll(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/* -------------------------------- global ---------------------------------- */
|
|
167
|
+
let client = new Redis(new MemoryRedis());
|
|
168
|
+
/** Register the default Redis driver used by `redis()`. */
|
|
169
|
+
export function setRedis(conn) {
|
|
170
|
+
client = new Redis(conn);
|
|
171
|
+
return client;
|
|
172
|
+
}
|
|
173
|
+
/** The default Redis client. */
|
|
174
|
+
export function redis() {
|
|
175
|
+
return client;
|
|
176
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request logging. `requestLogger()` middleware binds a child logger to each
|
|
3
|
+
* request with a generated `reqId`, so every log line within that request
|
|
4
|
+
* correlates — Fastify's `request.log`, built on Keel's `Logger.child()`. It can
|
|
5
|
+
* also log the request start and completion (method, path, status, duration).
|
|
6
|
+
*
|
|
7
|
+
* kernel.use(requestLogger()); // in app/Http/Kernel.ts
|
|
8
|
+
* requestLog().info("charging card"); // anywhere in the request → carries reqId
|
|
9
|
+
*/
|
|
10
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
11
|
+
import type { Logger } from "./logger.js";
|
|
12
|
+
export interface RequestLoggerOptions {
|
|
13
|
+
/** Generate the request id. Default: `crypto.randomUUID()`. */
|
|
14
|
+
genReqId?: (c: Context) => string;
|
|
15
|
+
/** Reuse an incoming id from this header if present (e.g. `"x-request-id"`). */
|
|
16
|
+
idHeader?: string;
|
|
17
|
+
/** Log request start and completion lines. Default: true. */
|
|
18
|
+
logRequests?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Middleware: attach a per-request child logger (with `reqId`) and log the request. */
|
|
21
|
+
export declare function requestLogger(options?: RequestLoggerOptions): MiddlewareHandler;
|
|
22
|
+
/**
|
|
23
|
+
* The current request's child logger (carrying its `reqId`), or the base logger
|
|
24
|
+
* when called outside a request or without `requestLogger()` installed.
|
|
25
|
+
*/
|
|
26
|
+
export declare function requestLog(): Logger;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request logging. `requestLogger()` middleware binds a child logger to each
|
|
3
|
+
* request with a generated `reqId`, so every log line within that request
|
|
4
|
+
* correlates — Fastify's `request.log`, built on Keel's `Logger.child()`. It can
|
|
5
|
+
* also log the request start and completion (method, path, status, duration).
|
|
6
|
+
*
|
|
7
|
+
* kernel.use(requestLogger()); // in app/Http/Kernel.ts
|
|
8
|
+
* requestLog().info("charging card"); // anywhere in the request → carries reqId
|
|
9
|
+
*/
|
|
10
|
+
import { ctx } from "./request.js";
|
|
11
|
+
import { logger } from "./helpers.js";
|
|
12
|
+
// The child logger for the current request, keyed by the context object.
|
|
13
|
+
const store = new WeakMap();
|
|
14
|
+
/** Middleware: attach a per-request child logger (with `reqId`) and log the request. */
|
|
15
|
+
export function requestLogger(options = {}) {
|
|
16
|
+
const { genReqId, idHeader, logRequests = true } = options;
|
|
17
|
+
return async (c, next) => {
|
|
18
|
+
const incoming = idHeader ? c.req.header(idHeader) : undefined;
|
|
19
|
+
const reqId = incoming ?? (genReqId ? genReqId(c) : crypto.randomUUID());
|
|
20
|
+
const log = logger().child({ reqId });
|
|
21
|
+
store.set(c, log);
|
|
22
|
+
const start = performance.now();
|
|
23
|
+
if (logRequests)
|
|
24
|
+
log.info("request", { method: c.req.method, path: c.req.path });
|
|
25
|
+
await next();
|
|
26
|
+
if (logRequests) {
|
|
27
|
+
log.info("request completed", {
|
|
28
|
+
status: c.res.status,
|
|
29
|
+
ms: Number((performance.now() - start).toFixed(1)),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The current request's child logger (carrying its `reqId`), or the base logger
|
|
36
|
+
* when called outside a request or without `requestLogger()` installed.
|
|
37
|
+
*/
|
|
38
|
+
export function requestLog() {
|
|
39
|
+
try {
|
|
40
|
+
const log = store.get(ctx());
|
|
41
|
+
if (log)
|
|
42
|
+
return log;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// not in a request context
|
|
46
|
+
}
|
|
47
|
+
return logger();
|
|
48
|
+
}
|
package/dist/core/request.d.ts
CHANGED
|
@@ -39,6 +39,12 @@ interface ResponseHelper {
|
|
|
39
39
|
status(code: number): ResponseHelper;
|
|
40
40
|
/** Set a response header (chainable). */
|
|
41
41
|
header(name: string, value: string): ResponseHelper;
|
|
42
|
+
/** Set several response headers at once (chainable). */
|
|
43
|
+
headers(map: Record<string, string>): ResponseHelper;
|
|
44
|
+
/** Read a response header set so far (e.g. in middleware after `next()`). */
|
|
45
|
+
getHeader(name: string): string | null;
|
|
46
|
+
/** Whether a response header has been set. */
|
|
47
|
+
hasHeader(name: string): boolean;
|
|
42
48
|
/** Set the Content-Type (chainable). */
|
|
43
49
|
type(mime: string): ResponseHelper;
|
|
44
50
|
/** Append a value to a (possibly multi-value) header (chainable). */
|
|
@@ -74,13 +80,23 @@ export declare const request: {
|
|
|
74
80
|
param(name?: string): string | Record<string, string>;
|
|
75
81
|
query(name?: string): string | undefined | Record<string, string>;
|
|
76
82
|
json<T = unknown>(): Promise<T>;
|
|
83
|
+
/**
|
|
84
|
+
* The raw request body as text — for content types `json()`/`all()` don't
|
|
85
|
+
* handle (XML, CSV, a custom format). Parse it yourself from here.
|
|
86
|
+
*/
|
|
87
|
+
text(): Promise<string>;
|
|
88
|
+
/** The raw request body as an ArrayBuffer (binary payloads). */
|
|
89
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
90
|
+
/** The raw request body as a Blob. */
|
|
91
|
+
blob(): Promise<Blob>;
|
|
77
92
|
/** The raw web Request. */
|
|
78
93
|
readonly raw: Request;
|
|
79
|
-
/** The matched route: `{ name, pattern, methods }`. */
|
|
94
|
+
/** The matched route: `{ name, pattern, methods, config }`. */
|
|
80
95
|
readonly route: {
|
|
81
96
|
name?: string;
|
|
82
97
|
pattern: string;
|
|
83
98
|
methods: import("./index.js").Method[];
|
|
99
|
+
config: Record<string, unknown>;
|
|
84
100
|
} | undefined;
|
|
85
101
|
/** Whether the matched route has the given name. */
|
|
86
102
|
routeIs(name: string): boolean;
|
package/dist/core/request.js
CHANGED
|
@@ -128,6 +128,17 @@ export const response = {
|
|
|
128
128
|
ctx().header(name, value);
|
|
129
129
|
return response;
|
|
130
130
|
},
|
|
131
|
+
headers(map) {
|
|
132
|
+
for (const [name, value] of Object.entries(map))
|
|
133
|
+
ctx().header(name, value);
|
|
134
|
+
return response;
|
|
135
|
+
},
|
|
136
|
+
getHeader(name) {
|
|
137
|
+
return ctx().res.headers.get(name);
|
|
138
|
+
},
|
|
139
|
+
hasHeader(name) {
|
|
140
|
+
return ctx().res.headers.has(name);
|
|
141
|
+
},
|
|
131
142
|
type(mime) {
|
|
132
143
|
ctx().header("content-type", mime);
|
|
133
144
|
return response;
|
|
@@ -196,11 +207,26 @@ export const request = {
|
|
|
196
207
|
json() {
|
|
197
208
|
return ctx().req.json();
|
|
198
209
|
},
|
|
210
|
+
/**
|
|
211
|
+
* The raw request body as text — for content types `json()`/`all()` don't
|
|
212
|
+
* handle (XML, CSV, a custom format). Parse it yourself from here.
|
|
213
|
+
*/
|
|
214
|
+
text() {
|
|
215
|
+
return ctx().req.text();
|
|
216
|
+
},
|
|
217
|
+
/** The raw request body as an ArrayBuffer (binary payloads). */
|
|
218
|
+
arrayBuffer() {
|
|
219
|
+
return ctx().req.arrayBuffer();
|
|
220
|
+
},
|
|
221
|
+
/** The raw request body as a Blob. */
|
|
222
|
+
blob() {
|
|
223
|
+
return ctx().req.blob();
|
|
224
|
+
},
|
|
199
225
|
/** The raw web Request. */
|
|
200
226
|
get raw() {
|
|
201
227
|
return ctx().req.raw;
|
|
202
228
|
},
|
|
203
|
-
/** The matched route: `{ name, pattern, methods }`. */
|
|
229
|
+
/** The matched route: `{ name, pattern, methods, config }`. */
|
|
204
230
|
get route() {
|
|
205
231
|
return ctx().get("route");
|
|
206
232
|
},
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task scheduling — declare recurring work with a fluent cadence, then let a
|
|
3
|
+
* cron trigger drive it. A code-defined scheduler, edge-first: you run the
|
|
4
|
+
* scheduler from a single per-minute trigger (Cloudflare Cron Triggers, or a
|
|
5
|
+
* Node interval), and it runs whatever's due.
|
|
6
|
+
*
|
|
7
|
+
* schedule(new PruneSessions()).daily();
|
|
8
|
+
* schedule(() => syncInventory()).everyFiveMinutes();
|
|
9
|
+
* schedule(new SendDigest()).cron("0 9 * * 1"); // 9am Mondays
|
|
10
|
+
*
|
|
11
|
+
* // from a Cloudflare `scheduled()` handler (or a Node setInterval):
|
|
12
|
+
* await scheduler().runDue(new Date(event.scheduledTime));
|
|
13
|
+
*
|
|
14
|
+
* A task is a `Job` or a plain function. `runDue(now)` runs every task whose
|
|
15
|
+
* cron expression matches `now` (to the minute).
|
|
16
|
+
*/
|
|
17
|
+
import { type Dispatchable } from "./queue.js";
|
|
18
|
+
/** Whether a 5-field cron expression matches `date` (to the minute, in the Date's own fields). */
|
|
19
|
+
export declare function cronMatches(expression: string, date: Date): boolean;
|
|
20
|
+
export declare class ScheduledTask {
|
|
21
|
+
readonly job: Dispatchable;
|
|
22
|
+
expression: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
constructor(job: Dispatchable);
|
|
25
|
+
/** Set a raw cron expression. */
|
|
26
|
+
cron(expression: string): this;
|
|
27
|
+
/** Label the task (for logging / introspection). */
|
|
28
|
+
named(name: string): this;
|
|
29
|
+
everyMinute(): this;
|
|
30
|
+
everyFiveMinutes(): this;
|
|
31
|
+
everyTenMinutes(): this;
|
|
32
|
+
everyFifteenMinutes(): this;
|
|
33
|
+
everyThirtyMinutes(): this;
|
|
34
|
+
hourly(): this;
|
|
35
|
+
/** At `minute` past every hour. */
|
|
36
|
+
hourlyAt(minute: number): this;
|
|
37
|
+
daily(): this;
|
|
38
|
+
/** Once a day at `HH:MM`. */
|
|
39
|
+
dailyAt(time: string): this;
|
|
40
|
+
/** Weekly on `weekday` (0 = Sunday) at midnight. */
|
|
41
|
+
weekly(weekday?: number): this;
|
|
42
|
+
/** Monthly on `day` at midnight. */
|
|
43
|
+
monthly(day?: number): this;
|
|
44
|
+
isDue(now: Date): boolean;
|
|
45
|
+
}
|
|
46
|
+
export declare class Scheduler {
|
|
47
|
+
readonly tasks: ScheduledTask[];
|
|
48
|
+
/** Register a task and return it for cadence configuration. */
|
|
49
|
+
schedule(job: Dispatchable): ScheduledTask;
|
|
50
|
+
/** The tasks due at `now`. */
|
|
51
|
+
due(now?: Date): ScheduledTask[];
|
|
52
|
+
/** Run every task due at `now` (in registration order); returns how many ran. */
|
|
53
|
+
runDue(now?: Date): Promise<number>;
|
|
54
|
+
}
|
|
55
|
+
/** The default scheduler. */
|
|
56
|
+
export declare function scheduler(): Scheduler;
|
|
57
|
+
/** Replace the default scheduler (e.g. to reset between tests). */
|
|
58
|
+
export declare function setScheduler(next: Scheduler): Scheduler;
|
|
59
|
+
/** Schedule a task on the default scheduler. */
|
|
60
|
+
export declare function schedule(job: Dispatchable): ScheduledTask;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task scheduling — declare recurring work with a fluent cadence, then let a
|
|
3
|
+
* cron trigger drive it. A code-defined scheduler, edge-first: you run the
|
|
4
|
+
* scheduler from a single per-minute trigger (Cloudflare Cron Triggers, or a
|
|
5
|
+
* Node interval), and it runs whatever's due.
|
|
6
|
+
*
|
|
7
|
+
* schedule(new PruneSessions()).daily();
|
|
8
|
+
* schedule(() => syncInventory()).everyFiveMinutes();
|
|
9
|
+
* schedule(new SendDigest()).cron("0 9 * * 1"); // 9am Mondays
|
|
10
|
+
*
|
|
11
|
+
* // from a Cloudflare `scheduled()` handler (or a Node setInterval):
|
|
12
|
+
* await scheduler().runDue(new Date(event.scheduledTime));
|
|
13
|
+
*
|
|
14
|
+
* A task is a `Job` or a plain function. `runDue(now)` runs every task whose
|
|
15
|
+
* cron expression matches `now` (to the minute).
|
|
16
|
+
*/
|
|
17
|
+
import { Job } from "./queue.js";
|
|
18
|
+
function runTask(job) {
|
|
19
|
+
return Promise.resolve(job instanceof Job ? job.handle() : job());
|
|
20
|
+
}
|
|
21
|
+
/* -------------------------------- cron ------------------------------------ */
|
|
22
|
+
/** Match one cron field (`*`, `5`, `1,2`, `1-5`, `*/5`, `0-30/10`) against a value. */
|
|
23
|
+
function fieldMatches(spec, value, min, max) {
|
|
24
|
+
if (spec === "*")
|
|
25
|
+
return true;
|
|
26
|
+
for (const part of spec.split(",")) {
|
|
27
|
+
const [range, stepStr] = part.split("/");
|
|
28
|
+
const step = stepStr ? Number(stepStr) : 1;
|
|
29
|
+
if (!Number.isFinite(step) || step < 1)
|
|
30
|
+
continue;
|
|
31
|
+
let lo;
|
|
32
|
+
let hi;
|
|
33
|
+
if (range === "*" || range === undefined) {
|
|
34
|
+
lo = min;
|
|
35
|
+
hi = max;
|
|
36
|
+
}
|
|
37
|
+
else if (range.includes("-")) {
|
|
38
|
+
const [a, b] = range.split("-");
|
|
39
|
+
lo = Number(a);
|
|
40
|
+
hi = Number(b);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
lo = Number(range);
|
|
44
|
+
hi = stepStr ? max : lo; // "5/10" means 5, 15, 25, … up to max
|
|
45
|
+
}
|
|
46
|
+
if (value >= lo && value <= hi && (value - lo) % step === 0)
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
/** Whether a 5-field cron expression matches `date` (to the minute, in the Date's own fields). */
|
|
52
|
+
export function cronMatches(expression, date) {
|
|
53
|
+
const parts = expression.trim().split(/\s+/);
|
|
54
|
+
if (parts.length !== 5)
|
|
55
|
+
throw new Error(`Invalid cron expression: "${expression}" (need 5 fields)`);
|
|
56
|
+
const [min, hour, dom, mon, dow] = parts;
|
|
57
|
+
const minuteOk = fieldMatches(min, date.getMinutes(), 0, 59);
|
|
58
|
+
const hourOk = fieldMatches(hour, date.getHours(), 0, 23);
|
|
59
|
+
const monthOk = fieldMatches(mon, date.getMonth() + 1, 1, 12);
|
|
60
|
+
const domOk = fieldMatches(dom, date.getDate(), 1, 31);
|
|
61
|
+
const dowOk = fieldMatches(dow, date.getDay(), 0, 6);
|
|
62
|
+
// Standard (Vixie) cron: when both day-of-month and day-of-week are restricted,
|
|
63
|
+
// the day matches if *either* does; otherwise both must match.
|
|
64
|
+
const dayOk = dom !== "*" && dow !== "*" ? domOk || dowOk : domOk && dowOk;
|
|
65
|
+
return minuteOk && hourOk && monthOk && dayOk;
|
|
66
|
+
}
|
|
67
|
+
/* ----------------------------- scheduled task ----------------------------- */
|
|
68
|
+
export class ScheduledTask {
|
|
69
|
+
job;
|
|
70
|
+
expression = "* * * * *";
|
|
71
|
+
name;
|
|
72
|
+
constructor(job) {
|
|
73
|
+
this.job = job;
|
|
74
|
+
}
|
|
75
|
+
/** Set a raw cron expression. */
|
|
76
|
+
cron(expression) {
|
|
77
|
+
this.expression = expression;
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
/** Label the task (for logging / introspection). */
|
|
81
|
+
named(name) {
|
|
82
|
+
this.name = name;
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
everyMinute() {
|
|
86
|
+
return this.cron("* * * * *");
|
|
87
|
+
}
|
|
88
|
+
everyFiveMinutes() {
|
|
89
|
+
return this.cron("*/5 * * * *");
|
|
90
|
+
}
|
|
91
|
+
everyTenMinutes() {
|
|
92
|
+
return this.cron("*/10 * * * *");
|
|
93
|
+
}
|
|
94
|
+
everyFifteenMinutes() {
|
|
95
|
+
return this.cron("*/15 * * * *");
|
|
96
|
+
}
|
|
97
|
+
everyThirtyMinutes() {
|
|
98
|
+
return this.cron("*/30 * * * *");
|
|
99
|
+
}
|
|
100
|
+
hourly() {
|
|
101
|
+
return this.cron("0 * * * *");
|
|
102
|
+
}
|
|
103
|
+
/** At `minute` past every hour. */
|
|
104
|
+
hourlyAt(minute) {
|
|
105
|
+
return this.cron(`${minute} * * * *`);
|
|
106
|
+
}
|
|
107
|
+
daily() {
|
|
108
|
+
return this.cron("0 0 * * *");
|
|
109
|
+
}
|
|
110
|
+
/** Once a day at `HH:MM`. */
|
|
111
|
+
dailyAt(time) {
|
|
112
|
+
const [h, m] = time.split(":");
|
|
113
|
+
return this.cron(`${Number(m)} ${Number(h)} * * *`);
|
|
114
|
+
}
|
|
115
|
+
/** Weekly on `weekday` (0 = Sunday) at midnight. */
|
|
116
|
+
weekly(weekday = 0) {
|
|
117
|
+
return this.cron(`0 0 * * ${weekday}`);
|
|
118
|
+
}
|
|
119
|
+
/** Monthly on `day` at midnight. */
|
|
120
|
+
monthly(day = 1) {
|
|
121
|
+
return this.cron(`0 0 ${day} * *`);
|
|
122
|
+
}
|
|
123
|
+
isDue(now) {
|
|
124
|
+
return cronMatches(this.expression, now);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/* ------------------------------- scheduler -------------------------------- */
|
|
128
|
+
export class Scheduler {
|
|
129
|
+
tasks = [];
|
|
130
|
+
/** Register a task and return it for cadence configuration. */
|
|
131
|
+
schedule(job) {
|
|
132
|
+
const task = new ScheduledTask(job);
|
|
133
|
+
this.tasks.push(task);
|
|
134
|
+
return task;
|
|
135
|
+
}
|
|
136
|
+
/** The tasks due at `now`. */
|
|
137
|
+
due(now = new Date()) {
|
|
138
|
+
return this.tasks.filter((t) => t.isDue(now));
|
|
139
|
+
}
|
|
140
|
+
/** Run every task due at `now` (in registration order); returns how many ran. */
|
|
141
|
+
async runDue(now = new Date()) {
|
|
142
|
+
let count = 0;
|
|
143
|
+
for (const task of this.tasks) {
|
|
144
|
+
if (task.isDue(now)) {
|
|
145
|
+
await runTask(task.job);
|
|
146
|
+
count++;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return count;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/* -------------------------------- global ---------------------------------- */
|
|
153
|
+
let instance = new Scheduler();
|
|
154
|
+
/** The default scheduler. */
|
|
155
|
+
export function scheduler() {
|
|
156
|
+
return instance;
|
|
157
|
+
}
|
|
158
|
+
/** Replace the default scheduler (e.g. to reset between tests). */
|
|
159
|
+
export function setScheduler(next) {
|
|
160
|
+
instance = next;
|
|
161
|
+
return instance;
|
|
162
|
+
}
|
|
163
|
+
/** Schedule a task on the default scheduler. */
|
|
164
|
+
export function schedule(job) {
|
|
165
|
+
return instance.schedule(job);
|
|
166
|
+
}
|
package/dist/core/session.js
CHANGED
|
@@ -11,6 +11,21 @@ import { getCookie, setCookie } from "hono/cookie";
|
|
|
11
11
|
import { ctx } from "./request.js";
|
|
12
12
|
const FLASH = "__flash";
|
|
13
13
|
const OLD = "__old";
|
|
14
|
+
/** UTF-8-safe base64 — plain btoa throws on non-Latin1 (emoji, many scripts). */
|
|
15
|
+
function b64encode(json) {
|
|
16
|
+
const bytes = new TextEncoder().encode(json);
|
|
17
|
+
let s = "";
|
|
18
|
+
for (const b of bytes)
|
|
19
|
+
s += String.fromCharCode(b);
|
|
20
|
+
return btoa(s);
|
|
21
|
+
}
|
|
22
|
+
function b64decode(raw) {
|
|
23
|
+
const s = atob(raw);
|
|
24
|
+
const bytes = new Uint8Array(s.length);
|
|
25
|
+
for (let i = 0; i < s.length; i++)
|
|
26
|
+
bytes[i] = s.charCodeAt(i);
|
|
27
|
+
return new TextDecoder().decode(bytes);
|
|
28
|
+
}
|
|
14
29
|
export class Session {
|
|
15
30
|
data;
|
|
16
31
|
constructor(data) {
|
|
@@ -81,7 +96,7 @@ export function sessionMiddleware(options = {}) {
|
|
|
81
96
|
const raw = getCookie(c, name);
|
|
82
97
|
if (raw) {
|
|
83
98
|
try {
|
|
84
|
-
data = JSON.parse(
|
|
99
|
+
data = JSON.parse(b64decode(raw));
|
|
85
100
|
}
|
|
86
101
|
catch {
|
|
87
102
|
/* tampered/expired — start fresh */
|
|
@@ -94,7 +109,7 @@ export function sessionMiddleware(options = {}) {
|
|
|
94
109
|
await next();
|
|
95
110
|
const toStore = { ...data };
|
|
96
111
|
delete toStore[OLD];
|
|
97
|
-
setCookie(c, name,
|
|
112
|
+
setCookie(c, name, b64encode(JSON.stringify(toStore)), {
|
|
98
113
|
httpOnly: true,
|
|
99
114
|
path: "/",
|
|
100
115
|
sameSite: "Lax",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File storage over a pluggable `Disk` — like the database and mail layers, the
|
|
3
|
+
* core imports no filesystem or SDK, so it runs on Node and the edge. Point a
|
|
4
|
+
* disk at the local filesystem (Node), S3 (`fetch`), or a Cloudflare R2 binding;
|
|
5
|
+
* `MemoryDisk` is the built-in default for tests.
|
|
6
|
+
*
|
|
7
|
+
* setDisk(new MemoryDisk()); // or setDisk(r2Disk(env.BUCKET))
|
|
8
|
+
* await storage().put("avatars/1.png", bytes);
|
|
9
|
+
* const bytes = await storage().get("avatars/1.png");
|
|
10
|
+
* const url = storage().url("avatars/1.png");
|
|
11
|
+
*
|
|
12
|
+
* Register several disks by name and select one with `storage("s3")`.
|
|
13
|
+
*/
|
|
14
|
+
/** The bridge to a storage backend — implement it once per backend. */
|
|
15
|
+
export interface Disk {
|
|
16
|
+
put(path: string, bytes: Uint8Array): Promise<void>;
|
|
17
|
+
get(path: string): Promise<Uint8Array | null>;
|
|
18
|
+
exists(path: string): Promise<boolean>;
|
|
19
|
+
delete(path: string): Promise<void>;
|
|
20
|
+
/** Paths currently stored, optionally filtered to those under `prefix`. */
|
|
21
|
+
list(prefix?: string): Promise<string[]>;
|
|
22
|
+
/** A URL for the stored object (public or signed — the disk decides). */
|
|
23
|
+
url(path: string): string;
|
|
24
|
+
}
|
|
25
|
+
export type Contents = string | Uint8Array | ArrayBuffer;
|
|
26
|
+
/** An in-memory `Disk` — the default; ideal for tests. Not shared across processes. */
|
|
27
|
+
export declare class MemoryDisk implements Disk {
|
|
28
|
+
private baseUrl;
|
|
29
|
+
private files;
|
|
30
|
+
constructor(baseUrl?: string);
|
|
31
|
+
put(path: string, bytes: Uint8Array): Promise<void>;
|
|
32
|
+
get(path: string): Promise<Uint8Array | null>;
|
|
33
|
+
exists(path: string): Promise<boolean>;
|
|
34
|
+
delete(path: string): Promise<void>;
|
|
35
|
+
list(prefix?: string): Promise<string[]>;
|
|
36
|
+
url(path: string): string;
|
|
37
|
+
}
|
|
38
|
+
export declare class Storage {
|
|
39
|
+
private disk;
|
|
40
|
+
constructor(disk: Disk);
|
|
41
|
+
/** Write a file (string, bytes, or ArrayBuffer — strings are UTF-8 encoded). */
|
|
42
|
+
put(path: string, contents: Contents): Promise<void>;
|
|
43
|
+
/** Read a file's raw bytes, or null if it doesn't exist. */
|
|
44
|
+
get(path: string): Promise<Uint8Array | null>;
|
|
45
|
+
/** Read a file as UTF-8 text, or null if it doesn't exist. */
|
|
46
|
+
getText(path: string): Promise<string | null>;
|
|
47
|
+
exists(path: string): Promise<boolean>;
|
|
48
|
+
delete(path: string): Promise<void>;
|
|
49
|
+
list(prefix?: string): Promise<string[]>;
|
|
50
|
+
url(path: string): string;
|
|
51
|
+
/** The underlying driver, for backend-specific operations. */
|
|
52
|
+
get driver(): Disk;
|
|
53
|
+
}
|
|
54
|
+
/** Register a disk, optionally under a name (default: `"default"`). */
|
|
55
|
+
export declare function setDisk(disk: Disk, name?: string): Storage;
|
|
56
|
+
/** The default disk, or a named one registered with `setDisk(disk, name)`. */
|
|
57
|
+
export declare function storage(name?: string): Storage;
|