@madojs/mado 0.5.0 → 0.6.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 +49 -1
- package/CHANGELOG.md +188 -0
- package/MADO_V1_PLAN.md +179 -0
- package/README.md +53 -14
- package/ROADMAP.md +36 -5
- package/TODO.md +72 -0
- package/dist/src/forms.d.ts +41 -7
- package/dist/src/forms.js +334 -59
- package/dist/src/forms.js.map +1 -1
- package/dist/src/html/bindings.d.ts +41 -0
- package/dist/src/html/bindings.js +163 -6
- package/dist/src/html/bindings.js.map +1 -1
- package/dist/src/html.d.ts +2 -0
- package/dist/src/html.js +1 -0
- package/dist/src/html.js.map +1 -1
- package/dist/src/index.d.ts +6 -6
- package/dist/src/index.js +2 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/page.d.ts +56 -0
- package/dist/src/page.js +17 -0
- package/dist/src/page.js.map +1 -1
- package/dist/src/router/manifest.d.ts +16 -1
- package/dist/src/router/manifest.js +181 -38
- package/dist/src/router/manifest.js.map +1 -1
- package/dist/src/router/match.d.ts +7 -2
- package/dist/src/router/match.js +14 -4
- package/dist/src/router/match.js.map +1 -1
- package/dist/src/router/navigation.d.ts +10 -0
- package/dist/src/router/navigation.js +73 -12
- package/dist/src/router/navigation.js.map +1 -1
- package/dist/src/signal.d.ts +15 -1
- package/dist/src/signal.js +112 -16
- package/dist/src/signal.js.map +1 -1
- package/docs/en/02-project-layout.md +99 -40
- package/docs/en/05-why-mado.md +1 -1
- package/docs/en/06-for-backenders.md +1 -1
- package/docs/en/07-llm-pitfalls.md +1 -1
- package/docs/en/09-shadow-vs-light-dom.md +60 -0
- package/docs/en/10-app-architecture.md +141 -0
- package/docs/en/11-layouts.md +115 -0
- package/docs/en/12-auth-and-api.md +217 -0
- package/docs/en/13-deployment.md +192 -0
- package/docs/en/14-testing.md +82 -0
- package/docs/en/15-error-handling.md +100 -0
- package/docs/en/16-bake-cookbook.md +93 -0
- package/docs/en/README.md +7 -0
- package/docs/fr/05-why-mado.md +1 -1
- package/docs/fr/06-for-backenders.md +1 -1
- package/docs/fr/07-llm-pitfalls.md +1 -1
- package/docs/fr/09-shadow-vs-light-dom.md +63 -0
- package/docs/fr/10-app-architecture.md +61 -0
- package/docs/fr/11-layouts.md +35 -0
- package/docs/fr/12-auth-and-api.md +35 -0
- package/docs/fr/13-deployment.md +39 -0
- package/docs/fr/14-testing.md +41 -0
- package/docs/fr/15-error-handling.md +50 -0
- package/docs/fr/16-bake-cookbook.md +35 -0
- package/docs/fr/README.md +7 -0
- package/docs/ru/05-why-mado.md +2 -2
- package/docs/ru/06-for-backenders.md +1 -1
- package/docs/ru/09-shadow-vs-light-dom.md +60 -0
- package/docs/ru/10-app-architecture.md +100 -0
- package/docs/ru/11-layouts.md +47 -0
- package/docs/ru/12-auth-and-api.md +53 -0
- package/docs/ru/13-deployment.md +60 -0
- package/docs/ru/14-testing.md +50 -0
- package/docs/ru/15-error-handling.md +56 -0
- package/docs/ru/16-bake-cookbook.md +55 -0
- package/docs/ru/README.md +7 -0
- package/docs/uk/06-for-backenders.md +2 -2
- package/docs/uk/09-shadow-vs-light-dom.md +91 -24
- package/docs/uk/10-app-architecture.md +56 -0
- package/docs/uk/11-layouts.md +34 -0
- package/docs/uk/12-auth-and-api.md +34 -0
- package/docs/uk/13-deployment.md +39 -0
- package/docs/uk/14-testing.md +34 -0
- package/docs/uk/15-error-handling.md +32 -0
- package/docs/uk/16-bake-cookbook.md +36 -0
- package/docs/uk/README.md +7 -0
- package/llms.txt +24 -1
- package/package.json +3 -1
- package/scripts/_config.mjs +224 -0
- package/scripts/bake.mjs +217 -120
- package/scripts/bundle.mjs +110 -67
- package/scripts/cli.mjs +127 -16
- package/scripts/preview.mjs +22 -12
- package/server/serve.mjs +101 -11
- package/starters/admin/README.md +63 -0
- package/starters/admin/index.html +21 -0
- package/starters/admin/mado.config.json +22 -0
- package/starters/admin/package.json +22 -0
- package/starters/admin/public/favicon.svg +4 -0
- package/starters/admin/src/components/x-button.ts +55 -0
- package/starters/admin/src/components/x-input.ts +74 -0
- package/starters/admin/src/layouts/app.ts +101 -0
- package/starters/admin/src/layouts/auth.ts +41 -0
- package/starters/admin/src/lib/api.ts +133 -0
- package/starters/admin/src/lib/auth.ts +83 -0
- package/starters/admin/src/main.ts +15 -0
- package/starters/admin/src/pages/admin/dashboard.ts +48 -0
- package/starters/admin/src/pages/admin/order-detail.ts +78 -0
- package/starters/admin/src/pages/admin/orders.ts +117 -0
- package/starters/admin/src/pages/home.ts +25 -0
- package/starters/admin/src/pages/login.ts +70 -0
- package/starters/admin/src/pages/not-found.ts +12 -0
- package/starters/admin/src/routes.ts +40 -0
- package/starters/admin/src/styles/global.ts +86 -0
- package/starters/admin/tsconfig.json +15 -0
- package/starters/crud/README.md +14 -2
- package/starters/crud/mado.config.json +20 -0
- package/starters/crud/package.json +9 -4
- package/starters/crud/src/components/app-shell.ts +13 -8
- package/starters/crud/src/main.ts +1 -4
- package/starters/crud/src/pages/ticket-detail.ts +1 -0
- package/starters/crud/src/pages/ticket-new.ts +1 -0
- package/starters/crud/src/pages/tickets.ts +1 -0
- package/starters/crud/src/routes.ts +4 -2
- package/starters/minimal/README.md +4 -2
- package/starters/minimal/mado.config.json +20 -0
- package/starters/minimal/package.json +8 -3
- package/starters/minimal/src/components/app-counter.ts +1 -1
- package/starters/minimal/src/routes.ts +4 -2
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# App architecture
|
|
2
|
+
|
|
3
|
+
This is the default shape for a production Mado app. It is intentionally boring:
|
|
4
|
+
one route manifest, one shell, one API client, one auth module, and page files
|
|
5
|
+
that own their feature components.
|
|
6
|
+
|
|
7
|
+
## File tree
|
|
8
|
+
|
|
9
|
+
```txt
|
|
10
|
+
src/
|
|
11
|
+
├── main.ts
|
|
12
|
+
├── routes.ts
|
|
13
|
+
├── layouts/
|
|
14
|
+
│ ├── app.ts
|
|
15
|
+
│ └── auth.ts
|
|
16
|
+
├── pages/
|
|
17
|
+
│ ├── home.ts
|
|
18
|
+
│ ├── login.ts
|
|
19
|
+
│ ├── not-found.ts
|
|
20
|
+
│ └── admin/
|
|
21
|
+
│ ├── dashboard.ts
|
|
22
|
+
│ ├── orders.ts
|
|
23
|
+
│ └── order-detail.ts
|
|
24
|
+
├── components/
|
|
25
|
+
│ ├── x-button.ts
|
|
26
|
+
│ └── x-input.ts
|
|
27
|
+
├── lib/
|
|
28
|
+
│ ├── api.ts
|
|
29
|
+
│ └── auth.ts
|
|
30
|
+
└── styles/
|
|
31
|
+
└── global.ts
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Keep business logic in `lib/`, route wrapping in `layouts/`, and UI leaves in
|
|
35
|
+
`components/`. A page should import the components it renders.
|
|
36
|
+
|
|
37
|
+
## Entry point
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// src/main.ts
|
|
41
|
+
import { html, render } from "@madojs/mado";
|
|
42
|
+
import "./styles/global.js";
|
|
43
|
+
import "./components/x-button.js";
|
|
44
|
+
import routesApi from "./routes.js";
|
|
45
|
+
|
|
46
|
+
render(html`${routesApi.view}`, document.getElementById("app")!);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Import global providers and tiny shared components here. Do not bulk-import
|
|
50
|
+
every feature component.
|
|
51
|
+
|
|
52
|
+
## Routes
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// src/routes.ts
|
|
56
|
+
import { layout, routes } from "@madojs/mado";
|
|
57
|
+
import { requireAuth } from "./lib/auth.js";
|
|
58
|
+
|
|
59
|
+
export const manifest = {
|
|
60
|
+
"/": () => import("./pages/home.js"),
|
|
61
|
+
"/login": layout({
|
|
62
|
+
layout: () => import("./layouts/auth.js"),
|
|
63
|
+
routes: { "/": () => import("./pages/login.js") },
|
|
64
|
+
}),
|
|
65
|
+
"/admin": layout({
|
|
66
|
+
layout: () => import("./layouts/app.js"),
|
|
67
|
+
guard: requireAuth,
|
|
68
|
+
routes: {
|
|
69
|
+
"/": () => import("./pages/admin/dashboard.js"),
|
|
70
|
+
"/orders": () => import("./pages/admin/orders.js"),
|
|
71
|
+
"/orders/:id": () => import("./pages/admin/order-detail.js"),
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
"*": () => import("./pages/not-found.js"),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export default routes(manifest, {
|
|
78
|
+
errorPage: (err) => html`<main><h1>Something went wrong</h1><pre>${err.message}</pre></main>`,
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Exporting `manifest` lets `mado bake` inspect the same route table.
|
|
83
|
+
|
|
84
|
+
## API and auth
|
|
85
|
+
|
|
86
|
+
Use one API client and one auth module. The admin starter ships a complete
|
|
87
|
+
version with token storage, a single-flight refresh request, and a route guard.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
// pages/admin/orders.ts
|
|
91
|
+
import { each, html, page, resource } from "@madojs/mado";
|
|
92
|
+
import { api } from "../../lib/api.js";
|
|
93
|
+
|
|
94
|
+
const orders = resource(() => "/api/orders", () => api.get("/orders"));
|
|
95
|
+
|
|
96
|
+
export default page({
|
|
97
|
+
title: "Orders",
|
|
98
|
+
view: () => html`
|
|
99
|
+
<main>
|
|
100
|
+
<h1>Orders</h1>
|
|
101
|
+
<ul>
|
|
102
|
+
${() => each(orders.data() ?? [], o => o.id, o => html`<li>${o.number}</li>`)}
|
|
103
|
+
</ul>
|
|
104
|
+
</main>
|
|
105
|
+
`,
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Mutations should declare invalidation near the write:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const save = mutation((payload) => api.post("/orders", payload), {
|
|
113
|
+
invalidates: ["/api/orders*"],
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Forms
|
|
118
|
+
|
|
119
|
+
Prefer one `useForm()` per user workflow.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const form = useForm({
|
|
123
|
+
email: { required: true, type: "email" },
|
|
124
|
+
"items.*.title": { required: true },
|
|
125
|
+
});
|
|
126
|
+
const items = form.array("items");
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Use dotted paths for arrays (`items.0.title`) and keep async validation in
|
|
130
|
+
`validateAsync` when it talks to the backend.
|
|
131
|
+
|
|
132
|
+
## Release
|
|
133
|
+
|
|
134
|
+
Local development uses `mado dev`. Production uses exactly one artifact:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
mado release
|
|
138
|
+
rsync -avz out/ user@server:/var/www/app/
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`out/` is the deploy folder. `dist/` is internal build output.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Layouts
|
|
2
|
+
|
|
3
|
+
> **One blessed path.** Layouts in Mado are nested-route groups with a shared
|
|
4
|
+
> shell. There is exactly one canonical place to declare a layout — your
|
|
5
|
+
> `routes.ts` manifest. Putting layout code anywhere else (in `main.ts`, in a
|
|
6
|
+
> page view, in a global custom-element wrapper) is a bug pattern: the LLM and
|
|
7
|
+
> the human both produce visually broken UI when they guess differently.
|
|
8
|
+
|
|
9
|
+
## The canonical recipe
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// src/routes.ts
|
|
13
|
+
import { layout, routes } from "@madojs/mado";
|
|
14
|
+
import { requireAuth } from "./lib/auth.js";
|
|
15
|
+
|
|
16
|
+
export const manifest = {
|
|
17
|
+
"/": () => import("./pages/home.js"), // no layout
|
|
18
|
+
"/login": layout({
|
|
19
|
+
layout: () => import("./layouts/auth.js"), // centered card
|
|
20
|
+
routes: { "/": () => import("./pages/login.js") },
|
|
21
|
+
}),
|
|
22
|
+
"/admin": layout({
|
|
23
|
+
layout: () => import("./layouts/app.js"), // admin shell
|
|
24
|
+
guard: requireAuth, // ← see 12-auth-and-api.md
|
|
25
|
+
routes: {
|
|
26
|
+
"/": () => import("./pages/admin/dashboard.js"),
|
|
27
|
+
"/orders": () => import("./pages/admin/orders.js"),
|
|
28
|
+
"/orders/:id": () => import("./pages/admin/order-detail.js"),
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
"*": () => import("./pages/not-found.js"),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export default routes(manifest);
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
A layout is just a `page({ view })` that renders `${ctx.child}` somewhere:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// src/layouts/app.ts
|
|
41
|
+
import { html, page } from "@madojs/mado";
|
|
42
|
+
import "../components/app-shell.js"; // <x-app-shell> (sidebar + topbar + slot)
|
|
43
|
+
|
|
44
|
+
export default page({
|
|
45
|
+
view: ({ child }) => html`
|
|
46
|
+
<x-app-shell>${child}</x-app-shell>
|
|
47
|
+
`,
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
That is the whole API.
|
|
52
|
+
|
|
53
|
+
- **Order of layouts** matters: outer groups wrap inner groups. The order in
|
|
54
|
+
the manifest is exactly the order of rendering.
|
|
55
|
+
- **One shell per group**, not one shell per page. If you want a different
|
|
56
|
+
shell for a subtree, create a new group with its own `layout`.
|
|
57
|
+
- **Layouts can be lazy** (`() => import(...)`). They are loaded together
|
|
58
|
+
with the page.
|
|
59
|
+
|
|
60
|
+
## Why "one blessed path"
|
|
61
|
+
|
|
62
|
+
Without this convention, every page accumulates `<x-app-shell>${...}</x-app-shell>`
|
|
63
|
+
boilerplate, the LLM eventually puts the shell wrapper into `main.ts` "to make
|
|
64
|
+
it consistent", and the next refactor produces the classic
|
|
65
|
+
*"navigation appears below the page content"* screenshot. The nested-routes
|
|
66
|
+
recipe makes the shell the **outer frame** structurally; there is no way to
|
|
67
|
+
re-order it by accident.
|
|
68
|
+
|
|
69
|
+
## Two acceptable alternatives (with caveats)
|
|
70
|
+
|
|
71
|
+
These exist for completeness. Reach for them only if you cannot use nested
|
|
72
|
+
routes.
|
|
73
|
+
|
|
74
|
+
### a) A single shell with the router slot in `main.ts`
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { html, render } from "@madojs/mado";
|
|
78
|
+
import "./components/app-shell.js";
|
|
79
|
+
import router from "./routes.js";
|
|
80
|
+
|
|
81
|
+
render(html`<x-app-shell>${router.view}</x-app-shell>`, app);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Caveat: every route now lives inside one shell. You cannot have a centered
|
|
85
|
+
login page or a marketing landing page without the admin chrome around it.
|
|
86
|
+
Use this only for single-shell apps.
|
|
87
|
+
|
|
88
|
+
### b) Per-page wrapping inside `view`
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
export default page({
|
|
92
|
+
view: () => html`
|
|
93
|
+
<x-app-shell>
|
|
94
|
+
<h1>Orders</h1>
|
|
95
|
+
...
|
|
96
|
+
</x-app-shell>
|
|
97
|
+
`,
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Caveat: repetition. Every new page must remember the wrapper. The first time
|
|
102
|
+
someone forgets it, the layout disappears and the LLM "fixes" it in the wrong
|
|
103
|
+
place. **Do not start with this.**
|
|
104
|
+
|
|
105
|
+
## Where to find more
|
|
106
|
+
|
|
107
|
+
- `src/page.ts` defines `layout()`, `page()`, `Guard` and `NestedRoutes`.
|
|
108
|
+
- `src/router/manifest.ts` flattens the nested manifest and applies guards
|
|
109
|
+
outer → inner before the page renders.
|
|
110
|
+
- The `admin` starter (`mado init my-app --starter admin`) ships with three
|
|
111
|
+
groups (`/`, `/login`, `/admin`) and is the reference implementation.
|
|
112
|
+
|
|
113
|
+
If you ever feel tempted to invent a fourth pattern, write it down in your
|
|
114
|
+
project `docs/` first and discuss it with the team. The cost of inconsistency
|
|
115
|
+
in this exact spot is higher than the cost of a slightly awkward layout.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Auth and API
|
|
2
|
+
|
|
3
|
+
> Mado has **zero runtime dependencies**, but that does not mean every team
|
|
4
|
+
> should reinvent its auth and HTTP boundary. This page is the blessed recipe.
|
|
5
|
+
> Copy it into your project, change the URLs and field names to match your
|
|
6
|
+
> backend, and stop touching it.
|
|
7
|
+
|
|
8
|
+
The `admin` starter (`mado init my-app --starter admin`) ships with these
|
|
9
|
+
files pre-installed in `src/lib/`:
|
|
10
|
+
|
|
11
|
+
- `api.ts` — `createApiClient(baseUrl)` + `accessToken` signal + `ApiError`
|
|
12
|
+
- `auth.ts` — `restoreSession()`, `login()`, `logout()`, `requireAuth` guard
|
|
13
|
+
|
|
14
|
+
The complete code is roughly 100 lines. Read it and own it.
|
|
15
|
+
|
|
16
|
+
## Mental model
|
|
17
|
+
|
|
18
|
+
- One **API boundary** (`api()`): every fetch in your app goes through it.
|
|
19
|
+
- One **memory-only access token** (`accessToken` signal): never in
|
|
20
|
+
`localStorage`. Renewed silently from an HttpOnly refresh cookie when needed.
|
|
21
|
+
- One **route guard** (`requireAuth`): plug it into the layout block that
|
|
22
|
+
wraps protected routes. The guard runs before the page is rendered.
|
|
23
|
+
|
|
24
|
+
That is the entire surface.
|
|
25
|
+
|
|
26
|
+
## `src/lib/api.ts`
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { signal } from "@madojs/mado";
|
|
30
|
+
|
|
31
|
+
export const accessToken = signal<string | null>(null);
|
|
32
|
+
|
|
33
|
+
export class ApiError extends Error {
|
|
34
|
+
constructor(public status: number, public body: unknown, message: string) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "ApiError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ApiInit extends Omit<RequestInit, "body"> {
|
|
41
|
+
json?: unknown;
|
|
42
|
+
baseUrl?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createApiClient(baseUrl: string) {
|
|
46
|
+
let refreshing: Promise<boolean> | null = null;
|
|
47
|
+
|
|
48
|
+
async function refresh(): Promise<boolean> {
|
|
49
|
+
if (refreshing) return refreshing;
|
|
50
|
+
refreshing = (async () => {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(new URL("/auth/refresh", baseUrl), {
|
|
53
|
+
method: "POST",
|
|
54
|
+
credentials: "include",
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) return false;
|
|
57
|
+
const data = (await res.json().catch(() => null)) as
|
|
58
|
+
| { accessToken?: string } | null;
|
|
59
|
+
if (!data?.accessToken) return false;
|
|
60
|
+
accessToken.set(data.accessToken);
|
|
61
|
+
return true;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
} finally {
|
|
65
|
+
refreshing = null;
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
return refreshing;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return async function api<T>(path: string, init: ApiInit = {}): Promise<T> {
|
|
72
|
+
const url = new URL(path, init.baseUrl ?? baseUrl);
|
|
73
|
+
const headers = new Headers(init.headers);
|
|
74
|
+
if (init.json !== undefined && !headers.has("content-type")) {
|
|
75
|
+
headers.set("content-type", "application/json");
|
|
76
|
+
}
|
|
77
|
+
const token = accessToken();
|
|
78
|
+
if (token) headers.set("authorization", `Bearer ${token}`);
|
|
79
|
+
|
|
80
|
+
const res = await fetch(url, {
|
|
81
|
+
...init,
|
|
82
|
+
headers,
|
|
83
|
+
credentials: init.credentials ?? "include",
|
|
84
|
+
body: init.json !== undefined ? JSON.stringify(init.json) : (init as RequestInit).body,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (res.status === 401) {
|
|
88
|
+
if (await refresh()) return api<T>(path, init);
|
|
89
|
+
accessToken.set(null);
|
|
90
|
+
throw new ApiError(401, null, "Unauthorized");
|
|
91
|
+
}
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
const body = await res.json().catch(() => null);
|
|
94
|
+
throw new ApiError(res.status, body, `HTTP ${res.status} ${res.statusText}`);
|
|
95
|
+
}
|
|
96
|
+
if (res.status === 204) return null as unknown as T;
|
|
97
|
+
return (await res.json()) as T;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const api = createApiClient("/api");
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Key invariants:
|
|
105
|
+
|
|
106
|
+
- **Bearer token in memory only.** A page reload destroys it; `restoreSession()`
|
|
107
|
+
brings it back from the refresh cookie.
|
|
108
|
+
- **Refresh is single-flight.** Five resources hitting 401 at the same time
|
|
109
|
+
trigger exactly one refresh request.
|
|
110
|
+
- **Errors are typed.** Catch `ApiError` for `.status` and `.body`.
|
|
111
|
+
- **`credentials: include`** is the default, because the refresh cookie is
|
|
112
|
+
cross-host-safe only with `include`.
|
|
113
|
+
|
|
114
|
+
## `src/lib/auth.ts`
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import type { Guard } from "@madojs/mado";
|
|
118
|
+
import { accessToken, api, ApiError } from "./api.js";
|
|
119
|
+
|
|
120
|
+
let restorePromise: Promise<boolean> | null = null;
|
|
121
|
+
|
|
122
|
+
export async function restoreSession(): Promise<boolean> {
|
|
123
|
+
if (accessToken()) return true;
|
|
124
|
+
if (restorePromise) return restorePromise;
|
|
125
|
+
restorePromise = (async () => {
|
|
126
|
+
try {
|
|
127
|
+
const data = await api<{ accessToken: string }>("/auth/refresh", {
|
|
128
|
+
method: "POST",
|
|
129
|
+
});
|
|
130
|
+
accessToken.set(data.accessToken);
|
|
131
|
+
return true;
|
|
132
|
+
} catch (e) {
|
|
133
|
+
if (e instanceof ApiError && e.status === 401) return false;
|
|
134
|
+
return false;
|
|
135
|
+
} finally {
|
|
136
|
+
restorePromise = null;
|
|
137
|
+
}
|
|
138
|
+
})();
|
|
139
|
+
return restorePromise;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const requireAuth: Guard = async ({ path }) => {
|
|
143
|
+
if (accessToken()) return;
|
|
144
|
+
if (await restoreSession()) return;
|
|
145
|
+
return { redirect: `/login?return=${encodeURIComponent(path)}`, replace: true };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export async function login(creds: { email: string; password: string }) {
|
|
149
|
+
const data = await api<{ accessToken: string }>("/auth/login", {
|
|
150
|
+
method: "POST",
|
|
151
|
+
json: creds,
|
|
152
|
+
});
|
|
153
|
+
accessToken.set(data.accessToken);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function logout() {
|
|
157
|
+
try { await api("/auth/logout", { method: "POST" }); } catch {}
|
|
158
|
+
accessToken.set(null);
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Drop `requireAuth` into your manifest:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
"/admin": layout({
|
|
166
|
+
layout: () => import("./layouts/app.js"),
|
|
167
|
+
guard: requireAuth, // ← entire group is now protected
|
|
168
|
+
routes: { ... },
|
|
169
|
+
}),
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The guard runs *before* the page is rendered. If the user is not signed in
|
|
173
|
+
and the refresh cookie cannot revive the session, they are redirected to
|
|
174
|
+
`/login?return=<original>`. After a successful sign-in, the login page reads
|
|
175
|
+
`return` and navigates back.
|
|
176
|
+
|
|
177
|
+
## Backend contract
|
|
178
|
+
|
|
179
|
+
The recipe assumes three endpoints. Adjust paths to taste:
|
|
180
|
+
|
|
181
|
+
| Endpoint | Request | Response (200) | Notes |
|
|
182
|
+
|-------------------------|---------------------------|------------------------------|--------------------------------|
|
|
183
|
+
| `POST /api/auth/login` | `{ email, password }` | `{ accessToken }` | Sets HttpOnly refresh cookie |
|
|
184
|
+
| `POST /api/auth/refresh`| (no body, cookie only) | `{ accessToken }` | Reads HttpOnly refresh cookie |
|
|
185
|
+
| `POST /api/auth/logout` | (no body) | `204` | Clears the refresh cookie |
|
|
186
|
+
|
|
187
|
+
If your backend uses a different shape (`{ token }`, `{ access_token, expires_in }`,
|
|
188
|
+
etc.), change `api.ts` and `auth.ts` in two places each. The rest of the app
|
|
189
|
+
keeps working.
|
|
190
|
+
|
|
191
|
+
## Dev proxy
|
|
192
|
+
|
|
193
|
+
In development, point `/api/*` at your backend with `mado.config.json`:
|
|
194
|
+
|
|
195
|
+
```jsonc
|
|
196
|
+
{
|
|
197
|
+
"dev": {
|
|
198
|
+
"port": 5173,
|
|
199
|
+
"proxy": { "/api": "http://localhost:3000" }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The dev server forwards requests under `/api/*` to your backend, so both the
|
|
205
|
+
SPA and the API can be reached from the same origin — no CORS dance during
|
|
206
|
+
development.
|
|
207
|
+
|
|
208
|
+
## When to deviate
|
|
209
|
+
|
|
210
|
+
- **SPA + cookies only** (no Bearer tokens). Remove the `authorization`
|
|
211
|
+
header and the `refresh()` retry; rely entirely on a session cookie.
|
|
212
|
+
- **Public site with optional auth.** Make `restoreSession()` opportunistic
|
|
213
|
+
on startup and skip the `requireAuth` guard.
|
|
214
|
+
- **Third-party API tokens** that cannot be refreshed. Drop `refresh()` and
|
|
215
|
+
fail loudly on 401.
|
|
216
|
+
|
|
217
|
+
Whatever you change, change it **in `api.ts` only**. Pages stay innocent.
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Deployment
|
|
2
|
+
|
|
3
|
+
> **One command. One artifact. Many hosts.** `mado release` writes everything
|
|
4
|
+
> a static host needs into `out/`. The same `out/` can be pushed to nginx,
|
|
5
|
+
> Cloudflare Pages, Netlify, S3 or GitHub Pages without re-building.
|
|
6
|
+
|
|
7
|
+
## What `mado release` produces
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
out/
|
|
11
|
+
├── index.html ← SPA shell (loads the bundle + boots the router)
|
|
12
|
+
├── assets/ ← hashed bundles (main-ABC.js, chunk-XYZ.js, …)
|
|
13
|
+
│ ├── *.gz ← precompressed gzip (gzip_static / Accept-Encoding)
|
|
14
|
+
│ └── *.br ← precompressed brotli (brotli_static / Accept-Encoding)
|
|
15
|
+
├── baked/ ← prerendered SEO HTML (mado bake)
|
|
16
|
+
│ ├── <route>/index.html
|
|
17
|
+
│ └── sitemap.xml
|
|
18
|
+
├── favicon.svg ← your public/ assets copied verbatim
|
|
19
|
+
├── _redirects ← Cloudflare Pages / Netlify SPA fallback
|
|
20
|
+
└── _headers ← Cloudflare Pages / Netlify cache rules
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`_redirects` and `_headers` are generated automatically and only if they do
|
|
24
|
+
not already exist in your project. They are safely ignored by nginx and other
|
|
25
|
+
hosts.
|
|
26
|
+
|
|
27
|
+
## Local rehearsal
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
mado release
|
|
31
|
+
mado preview # http://localhost:4173 — serves out/ exactly as a static host would
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`mado preview` mirrors the behavior described below: it picks `.br` over
|
|
35
|
+
`.gz` over raw, prefers baked HTML at `/<route>/`, and falls back to
|
|
36
|
+
`index.html` for unknown paths.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Recipe 1: VPS + nginx
|
|
41
|
+
|
|
42
|
+
The framework ships a production-ready [`nginx.conf`](../../nginx.conf) with
|
|
43
|
+
`gzip_static`, immutable cache for hashed bundles, and SPA fallback. Drop
|
|
44
|
+
`out/` into the host and point nginx at it.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Build the artifact locally
|
|
48
|
+
mado release
|
|
49
|
+
|
|
50
|
+
# Upload to the VPS
|
|
51
|
+
rsync -avz --delete out/ user@server:/var/www/myapp/
|
|
52
|
+
|
|
53
|
+
# On the VPS — first time only:
|
|
54
|
+
sudo cp /etc/nginx/conf.d/myapp.conf{,.bak}
|
|
55
|
+
sudo cp ./nginx.conf /etc/nginx/conf.d/myapp.conf
|
|
56
|
+
sudo nginx -t && sudo systemctl reload nginx
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Key lines of the shipped `nginx.conf`:
|
|
60
|
+
|
|
61
|
+
- `gzip_static on;` — serves the precompressed `.gz` files written by
|
|
62
|
+
`mado bundle`. Zero CPU at request time.
|
|
63
|
+
- `location ~* "^/(main|chunk|asset)-[A-Z0-9]+\.js$" { … immutable; }` —
|
|
64
|
+
hashed bundles get a one-year cache.
|
|
65
|
+
- `try_files $uri $uri/ /index.html;` — SPA fallback so deep links work
|
|
66
|
+
after a hard refresh.
|
|
67
|
+
|
|
68
|
+
Enable HTTPS with Let's Encrypt / Certbot. Add HSTS once you have it.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Recipe 2: Cloudflare Pages
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
mado release
|
|
76
|
+
npx wrangler pages deploy out --project-name=myapp
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- The generated `_redirects` (`/* /index.html 200`) gives you SPA fallback.
|
|
80
|
+
- The generated `_headers` (immutable cache for `/assets/*`, `no-cache` for
|
|
81
|
+
HTML) is honored by CF Pages.
|
|
82
|
+
- Baked routes (`out/baked/<route>/index.html`) take priority over the SPA
|
|
83
|
+
fallback because CF Pages matches static files first.
|
|
84
|
+
|
|
85
|
+
For preview branches, set the same build command in the CF Pages project:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
Build command: npm ci && npx mado release
|
|
89
|
+
Output directory: out
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
There is also a small **edge prerender PoC** in
|
|
93
|
+
[`examples/cloudflare`](../../examples/cloudflare/) for catalogs too big to
|
|
94
|
+
bake at build time.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Recipe 3: Static-only hosts (S3, Netlify, GitHub Pages)
|
|
99
|
+
|
|
100
|
+
Any static host works because `out/` is just files. Pick whichever you have:
|
|
101
|
+
|
|
102
|
+
**Netlify**
|
|
103
|
+
```bash
|
|
104
|
+
mado release
|
|
105
|
+
npx netlify deploy --prod --dir=out
|
|
106
|
+
```
|
|
107
|
+
`_redirects` and `_headers` are recognized natively.
|
|
108
|
+
|
|
109
|
+
**S3 / CloudFront**
|
|
110
|
+
```bash
|
|
111
|
+
mado release
|
|
112
|
+
aws s3 sync out/ s3://my-bucket/ --delete \
|
|
113
|
+
--cache-control "public, max-age=31536000, immutable" --exclude '*.html'
|
|
114
|
+
aws s3 sync out/ s3://my-bucket/ \
|
|
115
|
+
--cache-control "no-cache, must-revalidate" --include '*.html'
|
|
116
|
+
```
|
|
117
|
+
Configure CloudFront's "Default root object" to `index.html` and add a custom
|
|
118
|
+
error response: 403/404 → `/index.html` with status 200 (SPA fallback).
|
|
119
|
+
|
|
120
|
+
**GitHub Pages**
|
|
121
|
+
```bash
|
|
122
|
+
mado release
|
|
123
|
+
# Push out/ into the gh-pages branch (or use actions/upload-pages-artifact)
|
|
124
|
+
```
|
|
125
|
+
Pages handles `index.html` automatically. There is no native SPA fallback;
|
|
126
|
+
add a `404.html` that loads the SPA, or use the
|
|
127
|
+
[`spa-github-pages`](https://github.com/rafgraph/spa-github-pages) trick.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Cache-control matrix
|
|
132
|
+
|
|
133
|
+
| Path | Cache-Control | Why |
|
|
134
|
+
|------------------------------|--------------------------------------------------|----------------------------------|
|
|
135
|
+
| `/assets/main-*.js` | `public, max-age=31536000, immutable` | hashed filename → never reuse |
|
|
136
|
+
| `/assets/chunk-*.js` | `public, max-age=31536000, immutable` | same |
|
|
137
|
+
| `/*.html` | `no-cache, must-revalidate` | always reflect latest deploy |
|
|
138
|
+
| Other static files | `public, max-age=86400` | safe daily cache |
|
|
139
|
+
|
|
140
|
+
`mado release` writes these rules into `out/_headers` for CF / Netlify and
|
|
141
|
+
the shipped `nginx.conf` enforces them server-side.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## CI sketch (GitHub Actions)
|
|
146
|
+
|
|
147
|
+
```yaml
|
|
148
|
+
# .github/workflows/release.yml
|
|
149
|
+
name: release
|
|
150
|
+
on:
|
|
151
|
+
push:
|
|
152
|
+
branches: [main]
|
|
153
|
+
jobs:
|
|
154
|
+
release:
|
|
155
|
+
runs-on: ubuntu-latest
|
|
156
|
+
steps:
|
|
157
|
+
- uses: actions/checkout@v4
|
|
158
|
+
- uses: actions/setup-node@v4
|
|
159
|
+
with: { node-version: 22 }
|
|
160
|
+
- run: npm ci
|
|
161
|
+
- run: npx mado release
|
|
162
|
+
- uses: actions/upload-artifact@v4
|
|
163
|
+
with:
|
|
164
|
+
name: out
|
|
165
|
+
path: out
|
|
166
|
+
retention-days: 7
|
|
167
|
+
# Pick one deploy step:
|
|
168
|
+
# - run: rsync -avz out/ user@server:/var/www/myapp/
|
|
169
|
+
# - run: npx wrangler pages deploy out --project-name=myapp
|
|
170
|
+
# - run: npx netlify deploy --prod --dir=out
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Troubleshooting
|
|
176
|
+
|
|
177
|
+
- **404 on hard refresh of a deep link.** Your host did not pick up SPA
|
|
178
|
+
fallback. nginx: check `try_files`. CF/Netlify: `_redirects` is present?
|
|
179
|
+
S3+CloudFront: configure the 404 → `/index.html` (200) error response.
|
|
180
|
+
- **HTML is cached forever.** Either your host sent a default
|
|
181
|
+
`Cache-Control: public, max-age=...` or you are sitting behind a CDN that
|
|
182
|
+
ignores `no-cache`. Add an explicit rule mirroring the matrix above.
|
|
183
|
+
- **`/assets/*` files change but the browser keeps the old one.** They
|
|
184
|
+
should not — the filename is hashed by `mado bundle`. If you bypassed
|
|
185
|
+
bundle and shipped your own `dist/main.js`, give it a hash or short cache.
|
|
186
|
+
- **Baked SEO page shows `[object Object]`.** Should never happen after the
|
|
187
|
+
v1 bake update — bake now raises a loud error in that case. If you see it,
|
|
188
|
+
upgrade `@madojs/mado` and re-run `mado bake`.
|
|
189
|
+
|
|
190
|
+
See also: [`02-project-layout.md`](./02-project-layout.md) for the
|
|
191
|
+
`src/`/`dist/`/`public/`/`out/` model and [`03-static-bake.md`](./03-static-bake.md)
|
|
192
|
+
for the SEO bake mechanics.
|