@shaferllc/keel 0.68.0 → 0.77.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/AGENTS.md +2 -0
- package/README.md +15 -5
- package/dist/api/api.config.stub +9 -0
- package/dist/api/config.d.ts +13 -0
- package/dist/api/config.js +14 -0
- package/dist/api/index.d.ts +16 -0
- package/dist/api/index.js +13 -0
- package/dist/api/provider.d.ts +10 -0
- package/dist/api/provider.js +17 -0
- package/dist/api/query.d.ts +35 -0
- package/dist/api/query.js +42 -0
- package/dist/api/resource.d.ts +93 -0
- package/dist/api/resource.js +185 -0
- package/dist/core/application.js +6 -0
- package/dist/core/cache.d.ts +1 -2
- package/dist/core/cache.js +9 -2
- package/dist/core/cli/index.d.ts +28 -0
- package/dist/core/cli/index.js +427 -0
- package/dist/core/cli/stubs.d.ts +14 -0
- package/dist/core/cli/stubs.js +105 -0
- package/dist/core/console-prompt.d.ts +79 -0
- package/dist/core/console-prompt.js +239 -0
- package/dist/core/console-ui.d.ts +96 -0
- package/dist/core/console-ui.js +187 -0
- package/dist/core/console.d.ts +188 -0
- package/dist/core/console.js +395 -0
- package/dist/core/database.d.ts +70 -1
- package/dist/core/database.js +174 -15
- package/dist/core/env.d.ts +96 -0
- package/dist/core/env.js +140 -0
- package/dist/core/health.d.ts +2 -2
- package/dist/core/health.js +2 -2
- package/dist/core/http/kernel.d.ts +2 -0
- package/dist/core/http/kernel.js +48 -0
- package/dist/core/http/router.d.ts +5 -5
- package/dist/core/http/router.js +5 -5
- package/dist/core/i18n.d.ts +162 -0
- package/dist/core/i18n.js +472 -0
- package/dist/core/index.d.ts +25 -4
- package/dist/core/index.js +13 -3
- package/dist/core/instrumentation.d.ts +113 -0
- package/dist/core/instrumentation.js +52 -0
- package/dist/core/logger.d.ts +7 -0
- package/dist/core/logger.js +28 -1
- package/dist/core/notification.js +10 -1
- package/dist/core/package.d.ts +117 -0
- package/dist/core/package.js +169 -0
- package/dist/core/pages.d.ts +108 -0
- package/dist/core/pages.js +199 -0
- package/dist/core/queue.js +26 -5
- package/dist/core/repl.d.ts +33 -0
- package/dist/core/repl.js +88 -0
- package/dist/core/scheduler.js +6 -0
- package/dist/core/social.d.ts +4 -4
- package/dist/core/social.js +4 -4
- package/dist/core/storage.js +15 -3
- package/dist/core/telemetry.d.ts +208 -0
- package/dist/core/telemetry.js +383 -0
- package/dist/core/template.d.ts +2 -3
- package/dist/core/template.js +2 -3
- package/dist/core/testing.d.ts +171 -1
- package/dist/core/testing.js +505 -2
- package/dist/db/d1.js +13 -0
- package/dist/db/pg.d.ts +13 -0
- package/dist/db/pg.js +46 -4
- package/dist/openapi/config.d.ts +28 -0
- package/dist/openapi/config.js +25 -0
- package/dist/openapi/doc.d.ts +40 -0
- package/dist/openapi/doc.js +20 -0
- package/dist/openapi/export.d.ts +8 -0
- package/dist/openapi/export.js +19 -0
- package/dist/openapi/gate.d.ts +15 -0
- package/dist/openapi/gate.js +27 -0
- package/dist/openapi/index.d.ts +19 -0
- package/dist/openapi/index.js +15 -0
- package/dist/openapi/openapi.config.stub +29 -0
- package/dist/openapi/provider.d.ts +18 -0
- package/dist/openapi/provider.js +35 -0
- package/dist/openapi/routes.d.ts +9 -0
- package/dist/openapi/routes.js +23 -0
- package/dist/openapi/spec.d.ts +23 -0
- package/dist/openapi/spec.js +132 -0
- package/dist/openapi/ui.d.ts +8 -0
- package/dist/openapi/ui.js +31 -0
- package/dist/openapi/zod.d.ts +12 -0
- package/dist/openapi/zod.js +46 -0
- package/dist/watch/config.d.ts +33 -0
- package/dist/watch/config.js +38 -0
- package/dist/watch/entry.d.ts +53 -0
- package/dist/watch/entry.js +105 -0
- package/dist/watch/gate.d.ts +20 -0
- package/dist/watch/gate.js +32 -0
- package/dist/watch/index.d.ts +21 -0
- package/dist/watch/index.js +17 -0
- package/dist/watch/migration.d.ts +7 -0
- package/dist/watch/migration.js +20 -0
- package/dist/watch/provider.d.ts +22 -0
- package/dist/watch/provider.js +58 -0
- package/dist/watch/prune.d.ts +11 -0
- package/dist/watch/prune.js +22 -0
- package/dist/watch/recorder.d.ts +24 -0
- package/dist/watch/recorder.js +39 -0
- package/dist/watch/routes.d.ts +13 -0
- package/dist/watch/routes.js +55 -0
- package/dist/watch/store.d.ts +54 -0
- package/dist/watch/store.js +158 -0
- package/dist/watch/ui/dist/watch.css +1 -0
- package/dist/watch/ui/dist/watch.js +555 -0
- package/dist/watch/ui-shell.d.ts +12 -0
- package/dist/watch/ui-shell.js +24 -0
- package/dist/watch/watch.config.stub +47 -0
- package/dist/watch/watchers.d.ts +12 -0
- package/dist/watch/watchers.js +156 -0
- package/docs/ai-manifest.json +737 -3
- package/docs/api-resources.md +119 -0
- package/docs/configuration.md +74 -0
- package/docs/console.md +217 -13
- package/docs/database.md +101 -0
- package/docs/examples/api-resources.ts +86 -0
- package/docs/examples/configuration.ts +40 -0
- package/docs/examples/console.ts +134 -0
- package/docs/examples/database.ts +90 -0
- package/docs/examples/i18n.ts +117 -0
- package/docs/examples/logger.ts +74 -0
- package/docs/examples/mail.ts +103 -0
- package/docs/examples/pages.ts +82 -0
- package/docs/examples/telemetry.ts +127 -0
- package/docs/examples/testing.ts +150 -0
- package/docs/health.md +4 -4
- package/docs/i18n.md +302 -0
- package/docs/logger.md +156 -10
- package/docs/mail.md +73 -0
- package/docs/openapi.md +111 -0
- package/docs/packages.md +118 -0
- package/docs/pages.md +217 -0
- package/docs/storage.md +5 -3
- package/docs/telemetry.md +263 -0
- package/docs/templates.md +3 -4
- package/docs/testing.md +252 -0
- package/docs/watch.md +118 -0
- package/llms-full.txt +2389 -241
- package/llms.txt +13 -1
- package/package.json +32 -6
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-based routing — a file *is* a route.
|
|
3
|
+
*
|
|
4
|
+
* resources/pages/index.tsx -> /
|
|
5
|
+
* resources/pages/about.tsx -> /about
|
|
6
|
+
* resources/pages/users/index.tsx -> /users
|
|
7
|
+
* resources/pages/users/[id].tsx -> /users/:id
|
|
8
|
+
* resources/pages/docs/[...slug].tsx -> /docs/* (catch-all)
|
|
9
|
+
*
|
|
10
|
+
* A page is a module whose default export is a component. Everything else is
|
|
11
|
+
* optional: a `loader` to fetch its data, `middleware` to guard it, a `name` for
|
|
12
|
+
* URL generation.
|
|
13
|
+
*
|
|
14
|
+
* // resources/pages/users/[id].tsx
|
|
15
|
+
* export const middleware = [authGuard()];
|
|
16
|
+
* export const loader = async (c: Ctx) => db("users").find(c.req.param("id"));
|
|
17
|
+
*
|
|
18
|
+
* export default function UserPage({ params, data }: PageProps<{ id: string }, User>) {
|
|
19
|
+
* return <h1>{data.name}</h1>;
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* Register them once, in a provider:
|
|
23
|
+
*
|
|
24
|
+
* await pages(); // Node — scans resources/pages
|
|
25
|
+
* definePages(import.meta.glob("./pages/**\/*.tsx", { eager: true })); // edge/Vite
|
|
26
|
+
*
|
|
27
|
+
* This doesn't replace the router — it *drives* it. Every page becomes an
|
|
28
|
+
* ordinary named route, so `url()`, route middleware, and `keel routes` all see
|
|
29
|
+
* them, and you can mix pages and hand-written routes freely.
|
|
30
|
+
*/
|
|
31
|
+
import type { MiddlewareHandler } from "hono";
|
|
32
|
+
import { Router, type Ctx, type MiddlewareRef } from "./http/router.js";
|
|
33
|
+
import { type Renderable } from "./view.js";
|
|
34
|
+
/** What a page component receives. */
|
|
35
|
+
export interface PageProps<P extends Record<string, string> = Record<string, string>, D = unknown> {
|
|
36
|
+
/** The route parameters — `[id].tsx` gives you `params.id`. */
|
|
37
|
+
params: P;
|
|
38
|
+
/** Whatever the page's `loader` returned, or `undefined` if it has none. */
|
|
39
|
+
data: D;
|
|
40
|
+
/** The request context, for the rare page that needs it. */
|
|
41
|
+
ctx: Ctx;
|
|
42
|
+
}
|
|
43
|
+
/** The shape of a page module. Only the default export is required. */
|
|
44
|
+
export interface PageModule<P extends Record<string, string> = Record<string, string>, D = unknown> {
|
|
45
|
+
/** The component. It may be async. */
|
|
46
|
+
default: (props: PageProps<P, D>) => Renderable;
|
|
47
|
+
/** Fetch this page's data before it renders. Its return value becomes `data`. */
|
|
48
|
+
loader?: (ctx: Ctx) => D | Promise<D>;
|
|
49
|
+
/** Middleware for this page alone. */
|
|
50
|
+
middleware?: MiddlewareRef | MiddlewareRef[];
|
|
51
|
+
/** The route name. Defaults to one derived from the file path. */
|
|
52
|
+
name?: string;
|
|
53
|
+
/** Override the URL entirely, ignoring the file's location. */
|
|
54
|
+
path?: string;
|
|
55
|
+
}
|
|
56
|
+
/** A page, once it's been turned into a route. */
|
|
57
|
+
export interface RegisteredPage {
|
|
58
|
+
/** The file it came from, relative to the pages directory. */
|
|
59
|
+
file: string;
|
|
60
|
+
/** The URL pattern it was registered at. */
|
|
61
|
+
pattern: string;
|
|
62
|
+
/** Its route name. */
|
|
63
|
+
name: string;
|
|
64
|
+
}
|
|
65
|
+
export interface PagesOptions {
|
|
66
|
+
/** Where the pages live. Default: `"resources/pages"`. */
|
|
67
|
+
dir?: string;
|
|
68
|
+
/** Prefix every page's URL — `"/app"` puts `index.tsx` at `/app`. */
|
|
69
|
+
prefix?: string;
|
|
70
|
+
/** Middleware applied to every page. */
|
|
71
|
+
middleware?: MiddlewareRef[];
|
|
72
|
+
/** The router to register on. Defaults to the application's. */
|
|
73
|
+
router?: Router;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Turn a file path into a URL pattern.
|
|
77
|
+
*
|
|
78
|
+
* index.tsx -> /
|
|
79
|
+
* about.tsx -> /about
|
|
80
|
+
* users/index.tsx -> /users
|
|
81
|
+
* users/[id].tsx -> /users/:id
|
|
82
|
+
* docs/[...slug].tsx -> /docs/:slug{.+}
|
|
83
|
+
*/
|
|
84
|
+
export declare function routePattern(file: string): string;
|
|
85
|
+
/** A route name derived from the file path: `users/[id].tsx` -> `users.id`. */
|
|
86
|
+
export declare function routeName(file: string): string;
|
|
87
|
+
/**
|
|
88
|
+
* Turn a map of `file path -> page module` into routes.
|
|
89
|
+
*
|
|
90
|
+
* This is the edge-safe half: it does no filesystem work, so on Workers you hand
|
|
91
|
+
* it a build-time manifest —
|
|
92
|
+
*
|
|
93
|
+
* definePages(import.meta.glob("./pages/**\/*.tsx", { eager: true }));
|
|
94
|
+
*
|
|
95
|
+
* On Node, `pages()` builds that map for you by scanning a directory.
|
|
96
|
+
*/
|
|
97
|
+
export declare function definePages(modules: Record<string, PageModule<never, never>>, options?: PagesOptions): RegisteredPage[];
|
|
98
|
+
/**
|
|
99
|
+
* Scan a directory and register every page in it (Node only — it reads the
|
|
100
|
+
* filesystem). `node:fs` is imported dynamically, so the core still loads on the
|
|
101
|
+
* edge; there, use `definePages()` with a build-time manifest instead.
|
|
102
|
+
*
|
|
103
|
+
* await pages(); // resources/pages
|
|
104
|
+
* await pages({ dir: "app/pages", prefix: "/app" });
|
|
105
|
+
*/
|
|
106
|
+
export declare function pages(options?: PagesOptions): Promise<RegisteredPage[]>;
|
|
107
|
+
/** The middleware type a page may export, re-exported for convenience. */
|
|
108
|
+
export type PageMiddleware = MiddlewareRef | MiddlewareRef[] | MiddlewareHandler;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-based routing — a file *is* a route.
|
|
3
|
+
*
|
|
4
|
+
* resources/pages/index.tsx -> /
|
|
5
|
+
* resources/pages/about.tsx -> /about
|
|
6
|
+
* resources/pages/users/index.tsx -> /users
|
|
7
|
+
* resources/pages/users/[id].tsx -> /users/:id
|
|
8
|
+
* resources/pages/docs/[...slug].tsx -> /docs/* (catch-all)
|
|
9
|
+
*
|
|
10
|
+
* A page is a module whose default export is a component. Everything else is
|
|
11
|
+
* optional: a `loader` to fetch its data, `middleware` to guard it, a `name` for
|
|
12
|
+
* URL generation.
|
|
13
|
+
*
|
|
14
|
+
* // resources/pages/users/[id].tsx
|
|
15
|
+
* export const middleware = [authGuard()];
|
|
16
|
+
* export const loader = async (c: Ctx) => db("users").find(c.req.param("id"));
|
|
17
|
+
*
|
|
18
|
+
* export default function UserPage({ params, data }: PageProps<{ id: string }, User>) {
|
|
19
|
+
* return <h1>{data.name}</h1>;
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* Register them once, in a provider:
|
|
23
|
+
*
|
|
24
|
+
* await pages(); // Node — scans resources/pages
|
|
25
|
+
* definePages(import.meta.glob("./pages/**\/*.tsx", { eager: true })); // edge/Vite
|
|
26
|
+
*
|
|
27
|
+
* This doesn't replace the router — it *drives* it. Every page becomes an
|
|
28
|
+
* ordinary named route, so `url()`, route middleware, and `keel routes` all see
|
|
29
|
+
* them, and you can mix pages and hand-written routes freely.
|
|
30
|
+
*/
|
|
31
|
+
import { Router } from "./http/router.js";
|
|
32
|
+
import { View } from "./view.js";
|
|
33
|
+
import { app, make } from "./helpers.js";
|
|
34
|
+
/* ------------------------------ path -> route ----------------------------- */
|
|
35
|
+
const PAGE_FILE = /\.(tsx|jsx|ts|js)$/;
|
|
36
|
+
/** A `[...slug]` segment — the catch-all. */
|
|
37
|
+
const CATCH_ALL = /^\[\.\.\.(.+)\]$/;
|
|
38
|
+
/** A `[id]` segment — a route parameter. */
|
|
39
|
+
const PARAM = /^\[(.+)\]$/;
|
|
40
|
+
/**
|
|
41
|
+
* Turn a file path into a URL pattern.
|
|
42
|
+
*
|
|
43
|
+
* index.tsx -> /
|
|
44
|
+
* about.tsx -> /about
|
|
45
|
+
* users/index.tsx -> /users
|
|
46
|
+
* users/[id].tsx -> /users/:id
|
|
47
|
+
* docs/[...slug].tsx -> /docs/:slug{.+}
|
|
48
|
+
*/
|
|
49
|
+
export function routePattern(file) {
|
|
50
|
+
const segments = file
|
|
51
|
+
.replace(PAGE_FILE, "")
|
|
52
|
+
.split("/")
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
// A trailing `index` names its directory, not a child of it.
|
|
55
|
+
if (segments[segments.length - 1] === "index")
|
|
56
|
+
segments.pop();
|
|
57
|
+
const path = segments
|
|
58
|
+
.map((segment) => {
|
|
59
|
+
const catchAll = CATCH_ALL.exec(segment);
|
|
60
|
+
// Hono's `{.+}` makes the param greedy, so it swallows the slashes too.
|
|
61
|
+
if (catchAll)
|
|
62
|
+
return `:${catchAll[1]}{.+}`;
|
|
63
|
+
const param = PARAM.exec(segment);
|
|
64
|
+
if (param)
|
|
65
|
+
return `:${param[1]}`;
|
|
66
|
+
return segment;
|
|
67
|
+
})
|
|
68
|
+
.join("/");
|
|
69
|
+
return `/${path}`.replace(/\/+$/, "") || "/";
|
|
70
|
+
}
|
|
71
|
+
/** A route name derived from the file path: `users/[id].tsx` -> `users.id`. */
|
|
72
|
+
export function routeName(file) {
|
|
73
|
+
const segments = file
|
|
74
|
+
.replace(PAGE_FILE, "")
|
|
75
|
+
.split("/")
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
.map((segment) => segment.replace(CATCH_ALL, "$1").replace(PARAM, "$1"));
|
|
78
|
+
return segments.join(".") || "index";
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* How specific a pattern is: static beats dynamic beats catch-all.
|
|
82
|
+
*
|
|
83
|
+
* This ordering is the whole game. Register `/users/:id` before `/users/new` and
|
|
84
|
+
* the literal page is unreachable — `:id` matches "new" and wins. So pages are
|
|
85
|
+
* sorted before they're registered, most specific first, and the file layout
|
|
86
|
+
* stops being a trap.
|
|
87
|
+
*/
|
|
88
|
+
function specificity(pattern) {
|
|
89
|
+
return pattern
|
|
90
|
+
.split("/")
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.map((segment) => (segment.includes("{.+}") ? 2 : segment.startsWith(":") ? 1 : 0));
|
|
93
|
+
}
|
|
94
|
+
/** Sort patterns so the most specific route is matched first. */
|
|
95
|
+
function byPrecedence(a, b) {
|
|
96
|
+
const sa = specificity(a);
|
|
97
|
+
const sb = specificity(b);
|
|
98
|
+
// A catch-all anywhere in the path sinks it to the bottom.
|
|
99
|
+
const maxA = Math.max(0, ...sa);
|
|
100
|
+
const maxB = Math.max(0, ...sb);
|
|
101
|
+
if (maxA !== maxB)
|
|
102
|
+
return maxA - maxB;
|
|
103
|
+
// Then compare segment by segment: a literal beats a param at the same depth.
|
|
104
|
+
for (let i = 0; i < Math.max(sa.length, sb.length); i++) {
|
|
105
|
+
const va = sa[i] ?? -1;
|
|
106
|
+
const vb = sb[i] ?? -1;
|
|
107
|
+
if (va !== vb)
|
|
108
|
+
return va - vb;
|
|
109
|
+
}
|
|
110
|
+
// Same shape — longer paths (more segments) are more specific.
|
|
111
|
+
return sb.length - sa.length;
|
|
112
|
+
}
|
|
113
|
+
/* ------------------------------- registering ------------------------------ */
|
|
114
|
+
function list(mw) {
|
|
115
|
+
if (!mw)
|
|
116
|
+
return [];
|
|
117
|
+
return Array.isArray(mw) ? mw : [mw];
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Turn a map of `file path -> page module` into routes.
|
|
121
|
+
*
|
|
122
|
+
* This is the edge-safe half: it does no filesystem work, so on Workers you hand
|
|
123
|
+
* it a build-time manifest —
|
|
124
|
+
*
|
|
125
|
+
* definePages(import.meta.glob("./pages/**\/*.tsx", { eager: true }));
|
|
126
|
+
*
|
|
127
|
+
* On Node, `pages()` builds that map for you by scanning a directory.
|
|
128
|
+
*/
|
|
129
|
+
export function definePages(modules, options = {}) {
|
|
130
|
+
const router = options.router ?? app().make(Router);
|
|
131
|
+
const prefix = (options.prefix ?? "").replace(/\/+$/, "");
|
|
132
|
+
// Normalize the keys a glob gives us ("./pages/users/[id].tsx") down to the
|
|
133
|
+
// part that matters ("users/[id].tsx"), so both halves agree on the file name.
|
|
134
|
+
const dir = (options.dir ?? "resources/pages").replace(/^\.?\//, "").replace(/\/+$/, "");
|
|
135
|
+
const relative = (key) => key
|
|
136
|
+
.replace(/^\.?\//, "")
|
|
137
|
+
.replace(new RegExp(`^${dir}/`), "")
|
|
138
|
+
.replace(/^pages\//, "");
|
|
139
|
+
const entries = Object.entries(modules).map(([key, module]) => {
|
|
140
|
+
const file = relative(key);
|
|
141
|
+
// A prefix plus the root page would otherwise give "/app/".
|
|
142
|
+
const pattern = (prefix + (module.path ?? routePattern(file))).replace(/\/+$/, "") || "/";
|
|
143
|
+
return { file, module, pattern };
|
|
144
|
+
});
|
|
145
|
+
// Most specific first — see `specificity()` for why this matters.
|
|
146
|
+
entries.sort((a, b) => byPrecedence(a.pattern, b.pattern));
|
|
147
|
+
return entries.map(({ file, module, pattern }) => {
|
|
148
|
+
const name = module.name ?? routeName(file);
|
|
149
|
+
const route = router.get(pattern || "/", async (c) => {
|
|
150
|
+
const data = module.loader ? await module.loader(c) : undefined;
|
|
151
|
+
const html = await make(View).render(module.default({
|
|
152
|
+
params: c.req.param(),
|
|
153
|
+
data: data,
|
|
154
|
+
ctx: c,
|
|
155
|
+
}));
|
|
156
|
+
return c.html(html);
|
|
157
|
+
});
|
|
158
|
+
route.name(name);
|
|
159
|
+
const middleware = [...(options.middleware ?? []), ...list(module.middleware)];
|
|
160
|
+
if (middleware.length)
|
|
161
|
+
route.middleware(middleware);
|
|
162
|
+
return { file, pattern: pattern || "/", name };
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Scan a directory and register every page in it (Node only — it reads the
|
|
167
|
+
* filesystem). `node:fs` is imported dynamically, so the core still loads on the
|
|
168
|
+
* edge; there, use `definePages()` with a build-time manifest instead.
|
|
169
|
+
*
|
|
170
|
+
* await pages(); // resources/pages
|
|
171
|
+
* await pages({ dir: "app/pages", prefix: "/app" });
|
|
172
|
+
*/
|
|
173
|
+
export async function pages(options = {}) {
|
|
174
|
+
const { readdir } = await import("node:fs/promises");
|
|
175
|
+
const { join, relative: relativePath } = await import("node:path");
|
|
176
|
+
const { pathToFileURL } = await import("node:url");
|
|
177
|
+
const base = options.dir ?? "resources/pages";
|
|
178
|
+
const root = join(app().basePath, base);
|
|
179
|
+
const files = [];
|
|
180
|
+
async function walk(dir) {
|
|
181
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
182
|
+
for (const entry of entries) {
|
|
183
|
+
const full = join(dir, entry.name);
|
|
184
|
+
if (entry.isDirectory())
|
|
185
|
+
await walk(full);
|
|
186
|
+
// A leading underscore marks a file as private — a layout, a partial, a
|
|
187
|
+
// helper — so it can live beside the pages without becoming a URL.
|
|
188
|
+
else if (PAGE_FILE.test(entry.name) && !entry.name.startsWith("_"))
|
|
189
|
+
files.push(full);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
await walk(root);
|
|
193
|
+
const modules = {};
|
|
194
|
+
for (const file of files) {
|
|
195
|
+
const key = relativePath(root, file).split(/[\\/]/).join("/");
|
|
196
|
+
modules[key] = (await import(pathToFileURL(file).href));
|
|
197
|
+
}
|
|
198
|
+
return definePages(modules, { ...options, dir: base });
|
|
199
|
+
}
|
package/dist/core/queue.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* dead-letter list rather than vanishing.
|
|
26
26
|
*/
|
|
27
27
|
import { logger, hasApplication } from "./helpers.js";
|
|
28
|
+
import { instrument, currentRequestId } from "./instrumentation.js";
|
|
28
29
|
/**
|
|
29
30
|
* A unit of background work. Subclass and implement `handle`.
|
|
30
31
|
*
|
|
@@ -77,11 +78,30 @@ function policy(job, options) {
|
|
|
77
78
|
backoff: options.backoff ?? cls?.backoff ?? exponentialBackoff(1_000),
|
|
78
79
|
};
|
|
79
80
|
}
|
|
80
|
-
|
|
81
|
+
/** The display name of a dispatchable — its class name, or "fn" for a closure. */
|
|
82
|
+
function jobName(job) {
|
|
83
|
+
return job instanceof Job ? job.constructor.name : "fn";
|
|
84
|
+
}
|
|
85
|
+
async function invoke(job, context) {
|
|
86
|
+
const name = jobName(job);
|
|
87
|
+
const requestId = currentRequestId();
|
|
88
|
+
const start = Date.now();
|
|
89
|
+
instrument("job.processing", {
|
|
90
|
+
job: name,
|
|
91
|
+
...(job instanceof Job ? { payload: { ...job } } : {}),
|
|
92
|
+
...(requestId ? { requestId } : {}),
|
|
93
|
+
});
|
|
81
94
|
if (typeof job === "function")
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
95
|
+
await job();
|
|
96
|
+
else {
|
|
97
|
+
job.context = context;
|
|
98
|
+
await job.handle();
|
|
99
|
+
}
|
|
100
|
+
instrument("job.processed", {
|
|
101
|
+
job: name,
|
|
102
|
+
durationMs: Date.now() - start,
|
|
103
|
+
...(requestId ? { requestId } : {}),
|
|
104
|
+
});
|
|
85
105
|
}
|
|
86
106
|
/**
|
|
87
107
|
* Log through the application logger, falling back to the console. The queue is
|
|
@@ -103,12 +123,13 @@ function log(message, context) {
|
|
|
103
123
|
*/
|
|
104
124
|
async function reportFailure(job, error, context) {
|
|
105
125
|
log("queue: job failed", {
|
|
106
|
-
job: job
|
|
126
|
+
job: jobName(job),
|
|
107
127
|
jobId: context.jobId,
|
|
108
128
|
attempts: context.attempt,
|
|
109
129
|
queue: context.queue,
|
|
110
130
|
error,
|
|
111
131
|
});
|
|
132
|
+
instrument("job.failed", { job: jobName(job), error });
|
|
112
133
|
if (!(job instanceof Job))
|
|
113
134
|
return;
|
|
114
135
|
try {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An interactive shell with the application booted — `keel repl`.
|
|
3
|
+
*
|
|
4
|
+
* keel repl
|
|
5
|
+
* > await db("users").get()
|
|
6
|
+
* > make(Router).all()
|
|
7
|
+
* > .ls
|
|
8
|
+
*
|
|
9
|
+
* The point is that everything is *already wired*: the container is booted, the
|
|
10
|
+
* providers have run, and the global helpers are in scope. Poking at a model in a
|
|
11
|
+
* REPL is the fastest debugging loop there is, and it shouldn't cost you a
|
|
12
|
+
* throwaway script to get one.
|
|
13
|
+
*
|
|
14
|
+
* `node:repl` is imported dynamically, so the core still loads on the edge (where
|
|
15
|
+
* there is, of course, no REPL to start).
|
|
16
|
+
*/
|
|
17
|
+
import type { Application } from "./application.js";
|
|
18
|
+
/** A helper exposed in the REPL, listed by `.ls`. */
|
|
19
|
+
export interface ReplHelper {
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
value: unknown;
|
|
23
|
+
}
|
|
24
|
+
/** Extra helpers to put in scope, beyond the defaults. */
|
|
25
|
+
export interface ReplOptions {
|
|
26
|
+
helpers?: ReplHelper[];
|
|
27
|
+
/** The prompt string. Default: `"keel > "`. */
|
|
28
|
+
prompt?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Start the REPL. Resolves when the user exits it.
|
|
32
|
+
*/
|
|
33
|
+
export declare function startRepl(app: Application, options?: ReplOptions): Promise<void>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An interactive shell with the application booted — `keel repl`.
|
|
3
|
+
*
|
|
4
|
+
* keel repl
|
|
5
|
+
* > await db("users").get()
|
|
6
|
+
* > make(Router).all()
|
|
7
|
+
* > .ls
|
|
8
|
+
*
|
|
9
|
+
* The point is that everything is *already wired*: the container is booted, the
|
|
10
|
+
* providers have run, and the global helpers are in scope. Poking at a model in a
|
|
11
|
+
* REPL is the fastest debugging loop there is, and it shouldn't cost you a
|
|
12
|
+
* throwaway script to get one.
|
|
13
|
+
*
|
|
14
|
+
* `node:repl` is imported dynamically, so the core still loads on the edge (where
|
|
15
|
+
* there is, of course, no REPL to start).
|
|
16
|
+
*/
|
|
17
|
+
/** The helpers every Keel REPL gets. */
|
|
18
|
+
async function defaultHelpers(app) {
|
|
19
|
+
const core = await import("./index.js");
|
|
20
|
+
const helpers = [
|
|
21
|
+
{ name: "app", description: "The booted Application", value: app },
|
|
22
|
+
{ name: "config", description: "config(key, fallback?)", value: core.config },
|
|
23
|
+
{ name: "make", description: "Resolve a binding from the container", value: core.make },
|
|
24
|
+
{ name: "db", description: "Start a query: db('users').where(…)", value: core.db },
|
|
25
|
+
{ name: "cache", description: "The cache", value: core.cache },
|
|
26
|
+
{ name: "logger", description: "The logger", value: core.logger },
|
|
27
|
+
{ name: "events", description: "The event emitter", value: core.events },
|
|
28
|
+
{ name: "mail", description: "Start a message", value: core.mail },
|
|
29
|
+
{ name: "dispatch", description: "Dispatch a job", value: core.dispatch },
|
|
30
|
+
{ name: "storage", description: "The default disk", value: core.storage },
|
|
31
|
+
{ name: "router", description: "The router", value: app.make(core.Router) },
|
|
32
|
+
{ name: "hash", description: "Password hashing", value: core.hash },
|
|
33
|
+
{ name: "jwt", description: "JWT sign/verify", value: core.jwt },
|
|
34
|
+
{
|
|
35
|
+
name: "p",
|
|
36
|
+
description: "Await a promise and print the result — p(db('users').get())",
|
|
37
|
+
value: async (value) => await value,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
return helpers;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Start the REPL. Resolves when the user exits it.
|
|
44
|
+
*/
|
|
45
|
+
export async function startRepl(app, options = {}) {
|
|
46
|
+
const [{ start }, { join }] = await Promise.all([import("node:repl"), import("node:path")]);
|
|
47
|
+
const helpers = [...(await defaultHelpers(app)), ...(options.helpers ?? [])];
|
|
48
|
+
const name = app.config().get("app.name", "Keel");
|
|
49
|
+
console.log(`⚓ ${name} — type .ls to see what's in scope, .exit to leave.\n`);
|
|
50
|
+
const server = start({
|
|
51
|
+
prompt: options.prompt ?? "keel > ",
|
|
52
|
+
useColors: true,
|
|
53
|
+
// Await at the top level, so `> await db("users").get()` just works.
|
|
54
|
+
useGlobal: false,
|
|
55
|
+
breakEvalOnSigint: true,
|
|
56
|
+
});
|
|
57
|
+
for (const helper of helpers) {
|
|
58
|
+
Object.defineProperty(server.context, helper.name, {
|
|
59
|
+
configurable: false,
|
|
60
|
+
enumerable: true,
|
|
61
|
+
value: helper.value,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
// History, so the last thing you typed survives a restart.
|
|
65
|
+
try {
|
|
66
|
+
await new Promise((resolve) => {
|
|
67
|
+
server.setupHistory(join(app.basePath, ".keel_repl_history"), () => resolve());
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// No history file — not worth failing the REPL over.
|
|
72
|
+
}
|
|
73
|
+
server.defineCommand("ls", {
|
|
74
|
+
help: "List everything in scope",
|
|
75
|
+
action() {
|
|
76
|
+
const width = Math.max(...helpers.map((h) => h.name.length));
|
|
77
|
+
for (const helper of helpers) {
|
|
78
|
+
console.log(` ${helper.name.padEnd(width + 2)}${helper.description}`);
|
|
79
|
+
}
|
|
80
|
+
this.displayPrompt();
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
server.on("exit", () => {
|
|
85
|
+
void app.terminate().finally(() => resolve());
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
package/dist/core/scheduler.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* cron expression matches `now` (to the minute).
|
|
16
16
|
*/
|
|
17
17
|
import { Job } from "./queue.js";
|
|
18
|
+
import { instrument } from "./instrumentation.js";
|
|
18
19
|
function runTask(job) {
|
|
19
20
|
return Promise.resolve(job instanceof Job ? job.handle() : job());
|
|
20
21
|
}
|
|
@@ -142,7 +143,12 @@ export class Scheduler {
|
|
|
142
143
|
let count = 0;
|
|
143
144
|
for (const task of this.tasks) {
|
|
144
145
|
if (task.isDue(now)) {
|
|
146
|
+
const start = Date.now();
|
|
145
147
|
await runTask(task.job);
|
|
148
|
+
instrument("schedule.task.run", {
|
|
149
|
+
task: task.job instanceof Job ? task.job.constructor.name : "fn",
|
|
150
|
+
durationMs: Date.now() - start,
|
|
151
|
+
});
|
|
146
152
|
count++;
|
|
147
153
|
}
|
|
148
154
|
}
|
package/dist/core/social.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Social authentication — OAuth 2.0 "sign in with GitHub/Google/…".
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Social authentication — OAuth 2.0 "sign in with GitHub/Google/…". This owns the
|
|
3
|
+
* OAuth dance only: it hands you a normalized `SocialUser`, and *you*
|
|
4
|
+
* find-or-create your own user and log them in (with a session, `jwt`, or an
|
|
5
|
+
* access `token`). It stores nothing.
|
|
6
6
|
*
|
|
7
7
|
* const github = social.github({ clientId, clientSecret, redirectUri });
|
|
8
8
|
*
|
package/dist/core/social.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Social authentication — OAuth 2.0 "sign in with GitHub/Google/…".
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Social authentication — OAuth 2.0 "sign in with GitHub/Google/…". This owns the
|
|
3
|
+
* OAuth dance only: it hands you a normalized `SocialUser`, and *you*
|
|
4
|
+
* find-or-create your own user and log them in (with a session, `jwt`, or an
|
|
5
|
+
* access `token`). It stores nothing.
|
|
6
6
|
*
|
|
7
7
|
* const github = social.github({ clientId, clientSecret, redirectUri });
|
|
8
8
|
*
|
package/dist/core/storage.js
CHANGED
|
@@ -348,10 +348,22 @@ export function serveStorage(options = {}) {
|
|
|
348
348
|
const path = decodeURIComponent(url.pathname.slice(basePath.length + 1));
|
|
349
349
|
if (!path || path.includes(".."))
|
|
350
350
|
return next(); // path traversal guard
|
|
351
|
-
if (options.signed && !(await verifyStorageUrl(c.req.url))) {
|
|
352
|
-
return c.text("Forbidden", 403);
|
|
353
|
-
}
|
|
354
351
|
const store = storage(options.disk);
|
|
352
|
+
if (options.signed) {
|
|
353
|
+
// `signedUrl()` signs the path the *disk* reports. If the disk hands out
|
|
354
|
+
// `/storage/…` while this middleware is mounted at `/private`, no signature
|
|
355
|
+
// can ever match and every request would 403 — a misconfiguration that looks
|
|
356
|
+
// exactly like an expired link. Say so instead of failing quietly.
|
|
357
|
+
const diskPath = new URL(store.url(path), "http://keel.local").pathname;
|
|
358
|
+
if (diskPath !== url.pathname) {
|
|
359
|
+
throw new Error(`serveStorage: the disk serves "${path}" at "${diskPath}", but this middleware is ` +
|
|
360
|
+
`mounted at "${url.pathname}". signedUrl() signs the disk's own URL, so no signature ` +
|
|
361
|
+
`can match here. Give the disk the matching base URL (e.g. new MemoryDisk("${basePath}")) ` +
|
|
362
|
+
`or set basePath to the disk's prefix.`);
|
|
363
|
+
}
|
|
364
|
+
if (!(await verifyStorageUrl(c.req.url)))
|
|
365
|
+
return c.text("Forbidden", 403);
|
|
366
|
+
}
|
|
355
367
|
const bytes = await store.get(path);
|
|
356
368
|
if (bytes == null)
|
|
357
369
|
return next();
|