@askrjs/cli 0.0.2 → 0.0.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 +9 -7
- package/dist/add.js +554 -1
- package/dist/add.js.map +1 -0
- package/dist/cli.js +43 -14
- package/dist/cli.js.map +1 -1
- package/dist/discovery-Difb7Y4G.js +325 -0
- package/dist/discovery-Difb7Y4G.js.map +1 -0
- package/dist/generate.js +396 -0
- package/dist/generate.js.map +1 -0
- package/dist/openapi.js +5 -4
- package/dist/openapi.js.map +1 -1
- package/dist/planner-VAj7qlxr.js +287 -0
- package/dist/planner-VAj7qlxr.js.map +1 -0
- package/dist/range-YUs9eimn.js +127 -0
- package/dist/range-YUs9eimn.js.map +1 -0
- package/dist/registry-CyrRHRG5.js +211 -0
- package/dist/registry-CyrRHRG5.js.map +1 -0
- package/dist/skills/askr-dashboard-charts/SKILL.md +61 -44
- package/dist/skills/askr-dashboard-charts/agents/openai.yaml +2 -2
- package/dist/specification-DXnDOC-0.js +51 -0
- package/dist/specification-DXnDOC-0.js.map +1 -0
- package/dist/templates/full-stack/AGENTS.md +0 -1
- package/dist/templates/full-stack/README.md +0 -1
- package/dist/templates/full-stack/index.html +0 -1
- package/dist/templates/full-stack/package.json +4 -2
- package/dist/templates/full-stack/server.ts +5 -9
- package/dist/templates/full-stack/src/action-authorizations.ts +2 -2
- package/dist/templates/full-stack/src/actions/create-message.ts +5 -5
- package/dist/templates/full-stack/src/i18n.ts +4 -4
- package/dist/templates/full-stack/src/main.tsx +4 -5
- package/dist/templates/full-stack/src/pages/home.tsx +5 -5
- package/dist/templates/full-stack/src/pages/layout.tsx +15 -5
- package/dist/templates/full-stack/src/pages/not-found.tsx +5 -2
- package/dist/templates/full-stack/src/routes.tsx +11 -12
- package/dist/templates/full-stack/src/schemas.ts +1 -2
- package/dist/templates/full-stack/src/server/action-registry.ts +6 -10
- package/dist/templates/full-stack/src/server/actions/create-message.ts +4 -4
- package/dist/templates/full-stack/src/server/app.ts +67 -57
- package/dist/templates/full-stack/src/server/dependencies.ts +10 -10
- package/dist/templates/full-stack/src/server/entry-server.ts +3 -3
- package/dist/templates/full-stack/src/telemetry.ts +1 -2
- package/dist/templates/full-stack/src/vite-server.d.ts +2 -3
- package/dist/templates/full-stack/tests/actions/create-message.test.ts +7 -8
- package/dist/templates/full-stack/tsconfig.json +0 -1
- package/dist/templates/full-stack/vite.config.ts +5 -6
- package/dist/templates/spa/AGENTS.md +1 -1
- package/dist/templates/spa/package.json +1 -1
- package/dist/templates/spa/src/adapters/operations-client.ts +7 -2
- package/dist/templates/spa/src/pages/app/admin-home.tsx +21 -3
- package/dist/templates/spa/src/styles.css +1 -1
- package/dist/templates/ssg/tests/ssg-config.test.ts +3 -3
- package/dist/templates/ssr/server.ts +4 -10
- package/dist/templates/ssr/src/entry-server.tsx +6 -4
- package/dist/update.js +284 -2
- package/dist/update.js.map +1 -0
- package/dist/writer-D8qe_7ud.js +228 -0
- package/dist/writer-D8qe_7ud.js.map +1 -0
- package/package.json +11 -15
- package/skills/askr-dashboard-charts/SKILL.md +61 -44
- package/skills/askr-dashboard-charts/agents/openai.yaml +2 -2
- package/templates/full-stack/AGENTS.md +0 -1
- package/templates/full-stack/README.md +0 -1
- package/templates/full-stack/index.html +0 -1
- package/templates/full-stack/package.json +4 -2
- package/templates/full-stack/server.ts +5 -9
- package/templates/full-stack/src/action-authorizations.ts +2 -2
- package/templates/full-stack/src/actions/create-message.ts +5 -5
- package/templates/full-stack/src/i18n.ts +4 -4
- package/templates/full-stack/src/main.tsx +4 -5
- package/templates/full-stack/src/pages/home.tsx +5 -5
- package/templates/full-stack/src/pages/layout.tsx +15 -5
- package/templates/full-stack/src/pages/not-found.tsx +5 -2
- package/templates/full-stack/src/routes.tsx +11 -12
- package/templates/full-stack/src/schemas.ts +1 -2
- package/templates/full-stack/src/server/action-registry.ts +6 -10
- package/templates/full-stack/src/server/actions/create-message.ts +4 -4
- package/templates/full-stack/src/server/app.ts +67 -57
- package/templates/full-stack/src/server/dependencies.ts +10 -10
- package/templates/full-stack/src/server/entry-server.ts +3 -3
- package/templates/full-stack/src/telemetry.ts +1 -2
- package/templates/full-stack/src/vite-server.d.ts +2 -3
- package/templates/full-stack/tests/actions/create-message.test.ts +7 -8
- package/templates/full-stack/tsconfig.json +0 -1
- package/templates/full-stack/vite.config.ts +5 -6
- package/templates/spa/AGENTS.md +1 -1
- package/templates/spa/package.json +1 -1
- package/templates/spa/src/adapters/operations-client.ts +7 -2
- package/templates/spa/src/pages/app/admin-home.tsx +21 -3
- package/templates/spa/src/styles.css +1 -1
- package/templates/ssg/tests/ssg-config.test.ts +3 -3
- package/templates/ssr/server.ts +4 -10
- package/templates/ssr/src/entry-server.tsx +6 -4
- package/dist/add-DqhqRzBQ.js +0 -555
- package/dist/add-DqhqRzBQ.js.map +0 -1
- package/dist/update-DyW1na5V.js +0 -1140
- package/dist/update-DyW1na5V.js.map +0 -1
|
@@ -1,79 +1,96 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: askr-dashboard-charts
|
|
3
|
-
description:
|
|
3
|
+
description: Build product dashboards and metric-heavy Askr routes with route-owned async state, shared formatting, and typed @askrjs/charts plots. Use for stat cards, mixed charts, live or follow-latest data, zoom, brush, selection, accessible summaries, and chart exports.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Askr Dashboard Charts
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
## Use This When
|
|
11
|
-
|
|
12
|
-
- You are building a dashboard, metrics screen, stat-card surface, or chart-heavy route.
|
|
13
|
-
- The page needs resource-owned async loading plus chart composition.
|
|
14
|
-
- Charts and tables must share the same formatter logic.
|
|
15
|
-
- You need loading, empty, and error truth on a dense metrics screen.
|
|
8
|
+
Build dashboards that answer product questions without inventing parallel data, UI, or formatting systems.
|
|
16
9
|
|
|
17
10
|
## Inspect First
|
|
18
11
|
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
- Existing route or feature owners for the metrics you need
|
|
12
|
+
- Inspect the route or feature that owns the data.
|
|
13
|
+
- Inspect existing cards, tables, empty states, chart tokens, and format helpers.
|
|
14
|
+
- Inspect the nearest existing typed plot. In a generated SPA, start with `src/pages/app/admin-home.tsx`; in the CLI source, use `templates/spa/src/pages/app/admin-home.tsx`.
|
|
23
15
|
|
|
24
|
-
##
|
|
16
|
+
## Keep Ownership Clear
|
|
25
17
|
|
|
26
|
-
-
|
|
18
|
+
- Keep loading, empty, error, refresh, and composition state in the route or feature container.
|
|
27
19
|
- `src/components/shared` owns reusable stat cards, page headers, tables, and empty states.
|
|
28
|
-
- `src/features/<domain>` owns
|
|
20
|
+
- `src/features/<domain>` owns metric workflows and transforms.
|
|
29
21
|
- `src/shared` owns formatting helpers so cards, tables, and charts stay consistent.
|
|
22
|
+
- Keep row types app-owned; do not import package-owned datum types.
|
|
30
23
|
|
|
31
|
-
##
|
|
24
|
+
## Work In Order
|
|
32
25
|
|
|
33
|
-
1. Load dashboard data
|
|
34
|
-
2.
|
|
35
|
-
3.
|
|
36
|
-
4.
|
|
37
|
-
5.
|
|
26
|
+
1. Load dashboard data with `resource()` unless a wider owner already exists.
|
|
27
|
+
2. Model stable row keys and missing values before choosing marks.
|
|
28
|
+
3. Create each typed plot namespace once at module scope.
|
|
29
|
+
4. Compose the smallest plot that answers the product question.
|
|
30
|
+
5. Import `@askrjs/charts/styles` once at the app boundary.
|
|
31
|
+
6. Pair plots with summaries or on-demand data tables when precision matters.
|
|
38
32
|
|
|
39
|
-
##
|
|
33
|
+
## Compose A Typed Plot
|
|
40
34
|
|
|
41
35
|
```tsx
|
|
42
|
-
import {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
36
|
+
import { createPlot } from "@askrjs/charts";
|
|
37
|
+
|
|
38
|
+
type ThroughputRow = {
|
|
39
|
+
id: string;
|
|
40
|
+
timestamp: Date;
|
|
41
|
+
completed: number;
|
|
42
|
+
movingAverage: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const ThroughputPlot = createPlot<ThroughputRow>();
|
|
46
|
+
|
|
47
|
+
<ThroughputPlot.Root
|
|
48
|
+
data={rows}
|
|
49
|
+
rowKey="id"
|
|
50
|
+
label="Completed runs over time"
|
|
51
|
+
title="Run throughput"
|
|
52
|
+
description="Completed runs with a moving average."
|
|
53
|
+
>
|
|
54
|
+
<ThroughputPlot.Bar x="timestamp" y="completed" />
|
|
55
|
+
<ThroughputPlot.Line x="timestamp" y="movingAverage" />
|
|
56
|
+
<ThroughputPlot.Point x="timestamp" y="movingAverage" />
|
|
57
|
+
<ThroughputPlot.Zoom axes="x" />
|
|
58
|
+
<ThroughputPlot.Brush axis="x" modifier="shift" />
|
|
59
|
+
</ThroughputPlot.Root>;
|
|
60
|
+
```
|
|
46
61
|
|
|
47
|
-
|
|
48
|
-
if (dashboard.error) return <p role="alert">Unable to load dashboard.</p>;
|
|
62
|
+
## Handle Mixed, Live, And Interactive Data
|
|
49
63
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
- Use named scales and dual axes only when a mixed plot compares different units.
|
|
65
|
+
- Pass a reactive data getter for changing rows and update arrays with `appendPlotRows`, `upsertPlotRows`, `removePlotRows`, or `trimPlotRows`.
|
|
66
|
+
- Configure follow-latest by row count or time; let user pan or zoom pause following and provide an explicit `resumeLive()` action.
|
|
67
|
+
- Keep `view` and `selection` controlled when route or shared state owns them; otherwise use plot-local state.
|
|
68
|
+
- Add zoom, brush, legend filtering, or `onActivate` only when the workflow needs that interaction.
|
|
69
|
+
- Keep source and transformed exports distinguishable, and preserve access to visible or selected rows.
|
|
56
70
|
|
|
57
|
-
##
|
|
71
|
+
## Avoid
|
|
58
72
|
|
|
59
73
|
- Inline mock metrics inside page JSX.
|
|
60
74
|
- Hardcoded chart colors in runtime code.
|
|
61
75
|
- Decorative charts that do not answer a product question.
|
|
76
|
+
- Calling `createPlot()` inside a component or using unstable row keys.
|
|
77
|
+
- Treating missing or non-finite values as zero.
|
|
62
78
|
- Dense dashboards without loading, empty, and error states.
|
|
63
79
|
|
|
64
80
|
## Validate
|
|
65
81
|
|
|
66
|
-
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
82
|
+
- Verify cards, tables, tooltips, and exports share formatter logic; keep summaries and tooltip formatters valid for empty or missing rows.
|
|
83
|
+
- Verify mobile and desktop layouts in light and dark themes.
|
|
84
|
+
- Verify keyboard inspection, selection, zoom, brush, and the on-demand data table when used.
|
|
85
|
+
- Verify live updates retain stable selections and pause follow-latest after user navigation.
|
|
86
|
+
- Run the app's targeted tests and typecheck before its full acceptance command.
|
|
70
87
|
|
|
71
88
|
## Done When
|
|
72
89
|
|
|
73
|
-
- Route-owned
|
|
74
|
-
-
|
|
75
|
-
-
|
|
76
|
-
-
|
|
90
|
+
- Route-owned async states remain explicit.
|
|
91
|
+
- Typed rows and stable keys drive the plot.
|
|
92
|
+
- The plot supports the product question without becoming a component gallery.
|
|
93
|
+
- Accessible summaries and precise data remain available without relying on canvas pixels.
|
|
77
94
|
|
|
78
95
|
## Handoff
|
|
79
96
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
interface:
|
|
2
2
|
display_name: "Askr Dashboard Charts"
|
|
3
|
-
short_description: "
|
|
4
|
-
default_prompt: "Use $askr-dashboard-charts to build
|
|
3
|
+
short_description: "Compose typed Askr dashboard plots"
|
|
4
|
+
default_prompt: "Use $askr-dashboard-charts to build an accessible typed dashboard plot."
|
|
@@ -9,4 +9,3 @@ This project is the progressive full-stack stage of an Askr application.
|
|
|
9
9
|
- Keep `index.html` as the only document source. Preserve exactly one `<!--askr-head-->` and one `<!--askr-app-->` marker.
|
|
10
10
|
- Use native forms first; enhanced submission must preserve the same validation and authorization behavior.
|
|
11
11
|
- Never log cookies, authorization values, tokens, form fields, request bodies, or personal data.
|
|
12
|
-
|
|
@@ -15,4 +15,3 @@ npm run preview
|
|
|
15
15
|
Use `askr add action <name> --route <path>` to generate a browser-safe descriptor, server handler, composition-root registration, route authorization, and focused test.
|
|
16
16
|
|
|
17
17
|
`index.html` owns the static document. `@askrjs/vite/server` injects only Askr-owned head nodes and composes the streamed app response at `<!--askr-app-->`.
|
|
18
|
-
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"check": "npm run lint && npm run typecheck && npm test && npm run build"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@askrjs/auth": ">=0.0.1 <0.1.0",
|
|
18
17
|
"@askrjs/askr": ">=0.0.53 <0.1.0",
|
|
18
|
+
"@askrjs/auth": ">=0.0.1 <0.1.0",
|
|
19
19
|
"@askrjs/i18n": ">=0.0.1 <0.1.0",
|
|
20
20
|
"@askrjs/node": ">=0.0.1 <0.1.0",
|
|
21
21
|
"@askrjs/otel": ">=0.0.1 <0.1.0",
|
|
@@ -33,5 +33,7 @@
|
|
|
33
33
|
"vite-plus": "^0.2.4",
|
|
34
34
|
"vitest": "^4.1.10"
|
|
35
35
|
},
|
|
36
|
-
"engines": {
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
38
|
+
}
|
|
37
39
|
}
|
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import app from
|
|
1
|
+
import { serve } from "@askrjs/node";
|
|
2
|
+
import app from "virtual:askr-server";
|
|
3
3
|
|
|
4
4
|
const port = Number(process.env.PORT ?? 3000);
|
|
5
|
-
|
|
6
|
-
host: process.env.HOST ??
|
|
5
|
+
await serve(app, {
|
|
6
|
+
host: process.env.HOST ?? "127.0.0.1",
|
|
7
7
|
port,
|
|
8
|
+
assets: { root: new URL("./dist", import.meta.url).pathname },
|
|
8
9
|
});
|
|
9
|
-
|
|
10
|
-
const shutdown = () => server.close();
|
|
11
|
-
process.once('SIGINT', shutdown);
|
|
12
|
-
process.once('SIGTERM', shutdown);
|
|
13
|
-
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ActionDescriptor } from
|
|
2
|
-
import { createMessageAction } from
|
|
1
|
+
import type { ActionDescriptor } from "@askrjs/askr/actions";
|
|
2
|
+
import { createMessageAction } from "./actions/create-message";
|
|
3
3
|
|
|
4
4
|
const actionsByRoute: Readonly<Record<string, readonly ActionDescriptor[]>> = Object.freeze({
|
|
5
5
|
"/": [createMessageAction],
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { defineAction } from
|
|
2
|
-
import { MessageInput } from
|
|
1
|
+
import { defineAction } from "@askrjs/askr/actions";
|
|
2
|
+
import { MessageInput } from "../schemas";
|
|
3
3
|
|
|
4
|
-
export const createMessageActionRoute =
|
|
4
|
+
export const createMessageActionRoute = "/";
|
|
5
5
|
|
|
6
6
|
export const createMessageAction = defineAction({
|
|
7
|
-
id:
|
|
7
|
+
id: "create-message",
|
|
8
8
|
input: MessageInput,
|
|
9
|
-
invalidates: [
|
|
9
|
+
invalidates: ["messages"],
|
|
10
10
|
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { createI18n } from
|
|
1
|
+
import { createI18n } from "@askrjs/i18n";
|
|
2
2
|
|
|
3
|
-
export const messages = createI18n(
|
|
3
|
+
export const messages = createI18n("en", {
|
|
4
4
|
en: {
|
|
5
|
-
title: () =>
|
|
6
|
-
intro: () =>
|
|
5
|
+
title: () => "{{appName}}",
|
|
6
|
+
intro: () => "A progressive full-stack Askr application.",
|
|
7
7
|
},
|
|
8
8
|
});
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import { createSPA, hydrateSPA } from
|
|
2
|
-
import { pageRegistry } from
|
|
1
|
+
import { createSPA, hydrateSPA } from "@askrjs/askr/boot";
|
|
2
|
+
import { pageRegistry } from "./routes";
|
|
3
3
|
|
|
4
|
-
const root = document.getElementById(
|
|
5
|
-
if (!root) throw new Error(
|
|
4
|
+
const root = document.getElementById("app");
|
|
5
|
+
if (!root) throw new Error("Missing #app root element.");
|
|
6
6
|
|
|
7
7
|
if (root.hasChildNodes()) {
|
|
8
8
|
await hydrateSPA({ root, registry: pageRegistry });
|
|
9
9
|
} else {
|
|
10
10
|
await createSPA({ root, registry: pageRegistry });
|
|
11
11
|
}
|
|
12
|
-
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { ActionForm } from
|
|
2
|
-
import { createMessageAction } from
|
|
3
|
-
import { messages } from
|
|
1
|
+
import { ActionForm } from "@askrjs/askr/actions";
|
|
2
|
+
import { createMessageAction } from "../actions/create-message";
|
|
3
|
+
import { messages } from "../i18n";
|
|
4
4
|
|
|
5
5
|
export function HomePage() {
|
|
6
6
|
return (
|
|
7
7
|
<main>
|
|
8
|
-
<h1>{messages.text(
|
|
9
|
-
<p>{messages.text(
|
|
8
|
+
<h1>{messages.text("title")}</h1>
|
|
9
|
+
<p>{messages.text("intro")}</p>
|
|
10
10
|
<form method="post" action="/api/session">
|
|
11
11
|
<button type="submit">Start demo session</button>
|
|
12
12
|
</form>
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import { Link } from
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { Link } from "@askrjs/askr/router";
|
|
2
|
+
import {
|
|
3
|
+
Block,
|
|
4
|
+
Container,
|
|
5
|
+
Header,
|
|
6
|
+
Navbar,
|
|
7
|
+
NavBrand,
|
|
8
|
+
NavGroup,
|
|
9
|
+
NavLink,
|
|
10
|
+
} from "@askrjs/themes/components";
|
|
11
|
+
import { ThemeScope, ThemeToggle } from "@askrjs/themes/theme";
|
|
12
|
+
import { messages } from "../i18n";
|
|
5
13
|
|
|
6
14
|
function ScopedLayout({ children }: { children?: unknown }) {
|
|
7
15
|
return (
|
|
@@ -10,7 +18,9 @@ function ScopedLayout({ children }: { children?: unknown }) {
|
|
|
10
18
|
<Header sticky>
|
|
11
19
|
<Container paddingY="sm">
|
|
12
20
|
<Navbar aria-label="Primary navigation" width="full">
|
|
13
|
-
<NavBrand
|
|
21
|
+
<NavBrand>
|
|
22
|
+
<Link href="/">{messages.text("title")}</Link>
|
|
23
|
+
</NavBrand>
|
|
14
24
|
<NavGroup align="end">
|
|
15
25
|
<NavLink href="/">Home</NavLink>
|
|
16
26
|
<ThemeToggle aria-label="Toggle theme" />
|
|
@@ -1,20 +1,19 @@
|
|
|
1
|
-
import { createRouteRegistry, group, route } from
|
|
2
|
-
import { actionsFor } from
|
|
3
|
-
import { AppLayout } from
|
|
4
|
-
import { HomePage } from
|
|
5
|
-
import { NotFoundPage } from
|
|
1
|
+
import { createRouteRegistry, group, route } from "@askrjs/askr/router";
|
|
2
|
+
import { actionsFor } from "./action-authorizations";
|
|
3
|
+
import { AppLayout } from "./pages/layout";
|
|
4
|
+
import { HomePage } from "./pages/home";
|
|
5
|
+
import { NotFoundPage } from "./pages/not-found";
|
|
6
6
|
|
|
7
7
|
export const pageRegistry = createRouteRegistry(() => {
|
|
8
8
|
group({ layout: AppLayout }, () => {
|
|
9
|
-
route(
|
|
10
|
-
actions: actionsFor(
|
|
9
|
+
route("/", HomePage, {
|
|
10
|
+
actions: actionsFor("/"),
|
|
11
11
|
meta: {
|
|
12
|
-
title:
|
|
13
|
-
description:
|
|
14
|
-
html: { lang:
|
|
12
|
+
title: "{{appName}}",
|
|
13
|
+
description: "A progressive full-stack Askr application.",
|
|
14
|
+
html: { lang: "en", dir: "ltr" },
|
|
15
15
|
},
|
|
16
16
|
});
|
|
17
|
-
route(
|
|
17
|
+
route("/*", NotFoundPage);
|
|
18
18
|
});
|
|
19
19
|
});
|
|
20
|
-
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { schema } from
|
|
1
|
+
import { schema } from "@askrjs/schema";
|
|
2
2
|
|
|
3
3
|
export const MessageInput = schema.object({
|
|
4
4
|
value: schema.string({ minLength: 1, maxLength: 200 }),
|
|
@@ -8,4 +8,3 @@ export const Message = schema.object({
|
|
|
8
8
|
id: schema.string(),
|
|
9
9
|
value: schema.string(),
|
|
10
10
|
});
|
|
11
|
-
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createMessageAction } from
|
|
3
|
-
import { createMessage } from
|
|
4
|
-
import type { AppDependencies } from
|
|
1
|
+
import { handleAction } from "@askrjs/server/askr";
|
|
2
|
+
import { createMessageAction } from "../actions/create-message";
|
|
3
|
+
import { createMessage } from "./actions/create-message";
|
|
4
|
+
import type { AppDependencies } from "./dependencies";
|
|
5
5
|
|
|
6
|
-
export function
|
|
7
|
-
|
|
8
|
-
csrf: { secret: process.env.CSRF_SECRET ?? 'development-only-secret' },
|
|
9
|
-
});
|
|
10
|
-
registry.register(createMessageAction, createMessage);
|
|
11
|
-
return registry;
|
|
6
|
+
export function createActionHandlers() {
|
|
7
|
+
return [handleAction(createMessageAction, createMessage)] as const;
|
|
12
8
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { InferSchema } from
|
|
2
|
-
import { createMessageAction } from
|
|
3
|
-
import type { AppDependencies } from
|
|
1
|
+
import type { InferSchema } from "@askrjs/schema";
|
|
2
|
+
import { createMessageAction } from "../../actions/create-message";
|
|
3
|
+
import type { AppDependencies } from "../dependencies";
|
|
4
4
|
|
|
5
5
|
type Input = InferSchema<typeof createMessageAction.input>;
|
|
6
6
|
|
|
7
7
|
export async function createMessage(
|
|
8
8
|
_context: unknown,
|
|
9
9
|
input: Input,
|
|
10
|
-
deps: Pick<AppDependencies,
|
|
10
|
+
deps: Pick<AppDependencies, "actions">,
|
|
11
11
|
) {
|
|
12
12
|
await deps.actions.record({ action: createMessageAction.id, value: input.value });
|
|
13
13
|
return { result: { accepted: true } };
|
|
@@ -1,66 +1,76 @@
|
|
|
1
|
-
import { createAuth } from
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { createActions } from './action-registry';
|
|
10
|
-
import type { AppDependencies } from './dependencies';
|
|
1
|
+
import { createAuth } from "@askrjs/auth";
|
|
2
|
+
import { createAskrApp } from "@askrjs/server/askr";
|
|
3
|
+
import { csrf, rateLimit, requestId, securityHeaders } from "@askrjs/server/middleware";
|
|
4
|
+
import { pageRegistry } from "../routes";
|
|
5
|
+
import { Message, MessageInput } from "../schemas";
|
|
6
|
+
import { telemetry } from "../telemetry";
|
|
7
|
+
import { createActionHandlers } from "./action-registry";
|
|
8
|
+
import type { AppDependencies } from "./dependencies";
|
|
11
9
|
|
|
12
10
|
export function createApp(deps: AppDependencies) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
return createAskrApp({
|
|
12
|
+
name: "{{appName}}",
|
|
13
|
+
version: "1.0.0",
|
|
14
|
+
dependencies: deps,
|
|
15
|
+
pages: pageRegistry,
|
|
16
|
+
api: {
|
|
17
|
+
define(api) {
|
|
18
|
+
api
|
|
19
|
+
.post("/messages", {
|
|
20
|
+
input: {
|
|
21
|
+
body: {
|
|
22
|
+
schema: MessageInput,
|
|
23
|
+
mediaTypes: ["application/json"],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
documentation: { body: { required: true } },
|
|
27
|
+
async handler(ctx, input, dependencies) {
|
|
28
|
+
const message = { id: crypto.randomUUID(), value: input.body.value };
|
|
29
|
+
await dependencies.actions.record({
|
|
30
|
+
action: "api.create-message",
|
|
31
|
+
value: message.value,
|
|
32
|
+
});
|
|
33
|
+
return ctx.created(message);
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
.operationId("createMessage")
|
|
37
|
+
.summary("Create a message")
|
|
38
|
+
.tags("Messages")
|
|
39
|
+
.use(
|
|
40
|
+
csrf({ secret: process.env.CSRF_SECRET ?? "development-only-secret" }),
|
|
41
|
+
rateLimit({ store: deps.rateLimits, limit: 30, windowMs: 60_000 }),
|
|
42
|
+
)
|
|
43
|
+
.created(Message)
|
|
44
|
+
.badRequest()
|
|
45
|
+
.unprocessableEntity()
|
|
46
|
+
.tooManyRequests();
|
|
47
|
+
api
|
|
48
|
+
.post("/session", (ctx) => {
|
|
49
|
+
const response = ctx.redirect("/", 303);
|
|
50
|
+
return ctx.setCookie(response, "askr-session", "demo-session", {
|
|
51
|
+
httpOnly: true,
|
|
52
|
+
sameSite: "lax",
|
|
53
|
+
secure: ctx.url.protocol === "https:",
|
|
54
|
+
path: "/",
|
|
55
|
+
});
|
|
56
|
+
})
|
|
57
|
+
.operationId("createSession")
|
|
58
|
+
.summary("Create a demo session")
|
|
59
|
+
.seeOther();
|
|
21
60
|
},
|
|
22
61
|
},
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
62
|
+
auth: {
|
|
63
|
+
resolver: createAuth({
|
|
64
|
+
sessions: deps.sessions,
|
|
65
|
+
principals: deps.principals,
|
|
66
|
+
sessionCookie: "askr-session",
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
actions: {
|
|
70
|
+
handlers: createActionHandlers(),
|
|
71
|
+
csrf: { secret: process.env.CSRF_SECRET ?? "development-only-secret" },
|
|
28
72
|
},
|
|
29
|
-
})
|
|
30
|
-
.operationId('createMessage')
|
|
31
|
-
.summary('Create a message')
|
|
32
|
-
.tags('Messages')
|
|
33
|
-
.use(
|
|
34
|
-
csrf({ secret: process.env.CSRF_SECRET ?? 'development-only-secret' }),
|
|
35
|
-
rateLimit({ store: deps.rateLimits, limit: 30, windowMs: 60_000 }),
|
|
36
|
-
)
|
|
37
|
-
.created(Message)
|
|
38
|
-
.badRequest()
|
|
39
|
-
.unprocessableEntity()
|
|
40
|
-
.tooManyRequests();
|
|
41
|
-
|
|
42
|
-
const router = api.createRouter(deps);
|
|
43
|
-
router.post('/api/session', (ctx) => {
|
|
44
|
-
const response = ctx.redirect('/', 303);
|
|
45
|
-
return ctx.setCookie(response, 'askr-session', 'demo-session', {
|
|
46
|
-
httpOnly: true,
|
|
47
|
-
sameSite: 'lax',
|
|
48
|
-
secure: ctx.url.protocol === 'https:',
|
|
49
|
-
path: '/',
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
return createServerApp({
|
|
53
|
-
router,
|
|
54
|
-
auth: createAuth({
|
|
55
|
-
sessions: deps.sessions,
|
|
56
|
-
principals: deps.principals,
|
|
57
|
-
sessionCookie: 'askr-session',
|
|
58
|
-
}),
|
|
59
73
|
middleware: [requestId(), securityHeaders()],
|
|
60
74
|
telemetry,
|
|
61
|
-
fallback: createAskrPageHandler({
|
|
62
|
-
registry: pageRegistry,
|
|
63
|
-
actions: createActions(deps),
|
|
64
|
-
}),
|
|
65
75
|
});
|
|
66
76
|
}
|
|
@@ -13,7 +13,11 @@ export interface AppDependencies {
|
|
|
13
13
|
record(entry: { action: string; value: string }): Promise<void>;
|
|
14
14
|
};
|
|
15
15
|
readonly rateLimits: {
|
|
16
|
-
consume(
|
|
16
|
+
consume(
|
|
17
|
+
key: string,
|
|
18
|
+
limit: number,
|
|
19
|
+
windowMs: number,
|
|
20
|
+
): Promise<{
|
|
17
21
|
allowed: boolean;
|
|
18
22
|
remaining: number;
|
|
19
23
|
reset: number;
|
|
@@ -25,23 +29,19 @@ export function createDependencies(): AppDependencies {
|
|
|
25
29
|
const counters = new Map<string, { count: number; reset: number }>();
|
|
26
30
|
return {
|
|
27
31
|
sessions: {
|
|
28
|
-
get: async (id) => id ===
|
|
29
|
-
? { id, subject: 'demo-user' }
|
|
30
|
-
: null,
|
|
32
|
+
get: async (id) => (id === "demo-session" ? { id, subject: "demo-user" } : null),
|
|
31
33
|
},
|
|
32
34
|
principals: {
|
|
33
|
-
get: async (subject) =>
|
|
34
|
-
? { id: subject, subject, permissions: [
|
|
35
|
-
: null,
|
|
35
|
+
get: async (subject) =>
|
|
36
|
+
subject === "demo-user" ? { id: subject, subject, permissions: ["messages:create"] } : null,
|
|
36
37
|
},
|
|
37
38
|
actions: { record: async () => undefined },
|
|
38
39
|
rateLimits: {
|
|
39
40
|
async consume(key, limit, windowMs) {
|
|
40
41
|
const now = Date.now();
|
|
41
42
|
const current = counters.get(key);
|
|
42
|
-
const window =
|
|
43
|
-
? { count: 0, reset: now + windowMs }
|
|
44
|
-
: current;
|
|
43
|
+
const window =
|
|
44
|
+
!current || current.reset <= now ? { count: 0, reset: now + windowMs } : current;
|
|
45
45
|
window.count += 1;
|
|
46
46
|
counters.set(key, window);
|
|
47
47
|
return {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { createApp } from
|
|
2
|
-
import { createDependencies } from
|
|
1
|
+
import { createApp } from "./app";
|
|
2
|
+
import { createDependencies } from "./dependencies";
|
|
3
3
|
|
|
4
4
|
const app = createApp(createDependencies());
|
|
5
5
|
|
|
6
6
|
export { app };
|
|
7
|
-
export { telemetry } from
|
|
7
|
+
export { telemetry } from "../telemetry";
|
|
8
8
|
export default app;
|