@hozu/cli 0.18.1 → 0.19.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/dist/agent.d.ts +19 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +33 -0
- package/dist/agent.js.map +1 -0
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +1 -0
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/browse-page.d.ts.map +1 -1
- package/dist/commands/browse-page.js +8 -2
- package/dist/commands/browse-page.js.map +1 -1
- package/dist/commands/browse-tab.d.ts +10 -0
- package/dist/commands/browse-tab.d.ts.map +1 -1
- package/dist/commands/browse-tab.js +44 -1
- package/dist/commands/browse-tab.js.map +1 -1
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +42 -3
- package/dist/commands/browse.js.map +1 -1
- package/dist/commands/docs.js +1 -1
- package/dist/commands/scaffold.js +8 -8
- package/dist/commands/scaffold.js.map +1 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +7 -0
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/skill.js +1 -1
- package/dist/contract.d.ts +4 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/guide.d.ts +27 -0
- package/dist/guide.d.ts.map +1 -0
- package/dist/guide.js +48 -0
- package/dist/guide.js.map +1 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +2 -1
- package/dist/main.js.map +1 -1
- package/dist/migrate/steps.d.ts.map +1 -1
- package/dist/migrate/steps.js +7 -0
- package/dist/migrate/steps.js.map +1 -1
- package/package.json +13 -9
- package/schema/browse.schema.json +13 -0
- package/schema/inspect.schema.json +20 -0
- package/skill/SKILL.md +63 -0
- package/skill/example/app.css +1 -0
- package/skill/example/app.ts +35 -0
- package/skill/example/features/bookmarks/feature.ts +13 -0
- package/skill/example/features/bookmarks/model.ts +163 -0
- package/skill/example/features/bookmarks/views.ts +156 -0
- package/skill/example/hozu.config.ts +34 -0
- package/skill/example/previews.ts +13 -0
- package/skill/example/routes.ts +11 -0
- package/skill/example/ui/badge.ts +11 -0
- package/skill/example/ui/button.ts +23 -0
- package/skill/example/ui/field.ts +20 -0
- package/skill/example/ui/input.ts +33 -0
- package/skill/example/ui/kit.ts +7 -0
- package/skill/example/ui/tv.ts +12 -0
- package/skill/topics/auth.md +56 -0
- package/skill/topics/components.md +92 -0
- package/skill/topics/content.md +37 -0
- package/skill/topics/contracts.md +36 -0
- package/skill/topics/data.md +74 -0
- package/skill/topics/deploy.md +73 -0
- package/skill/topics/diagnostics.md +99 -0
- package/skill/topics/endpoints.md +39 -0
- package/skill/topics/env.md +44 -0
- package/skill/topics/feature.md +90 -0
- package/skill/topics/fetch.md +67 -0
- package/skill/topics/forms.md +47 -0
- package/skill/topics/http.md +21 -0
- package/skill/topics/i18n.md +34 -0
- package/skill/topics/machine.md +78 -0
- package/skill/topics/pages.md +69 -0
- package/skill/topics/patterns.md +85 -0
- package/skill/topics/recipes.md +73 -0
- package/skill/topics/requests.md +41 -0
- package/skill/topics/testing.md +84 -0
- package/skill/topics/views.md +51 -0
- package/templates/guide.md +16 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ui } from '@hozu/core'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { tv } from './tv.ts'
|
|
4
|
+
|
|
5
|
+
const styles = tv({
|
|
6
|
+
slots: { base: 'flex-1 space-y-1', label: 'sr-only', error: 'text-sm text-rose-600' },
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
export const Field = ui.component({
|
|
10
|
+
tag: 'div',
|
|
11
|
+
styles,
|
|
12
|
+
props: z.object({ for: z.string(), label: z.string(), error: z.string().nullable(), errorId: z.string() }),
|
|
13
|
+
slots: ['control'],
|
|
14
|
+
render: ({ props, slots, classes }) =>
|
|
15
|
+
ui.div({}, [
|
|
16
|
+
ui.label({ for: props.for, class: classes.label }, [props.label]),
|
|
17
|
+
slots.control,
|
|
18
|
+
ui.p({ id: props.errorId, class: classes.error }, [props.error]),
|
|
19
|
+
]),
|
|
20
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ui } from '@hozu/core'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { tv } from './tv.ts'
|
|
4
|
+
|
|
5
|
+
const styles = tv({ base: 'w-full rounded border px-3 py-2 aria-invalid:border-rose-500' })
|
|
6
|
+
|
|
7
|
+
export const Input = ui.component({
|
|
8
|
+
tag: 'input',
|
|
9
|
+
styles,
|
|
10
|
+
props: z.object({
|
|
11
|
+
id: z.string(),
|
|
12
|
+
name: z.string(),
|
|
13
|
+
value: z.string(),
|
|
14
|
+
required: z.boolean().default(false),
|
|
15
|
+
minlength: z.number().optional(),
|
|
16
|
+
maxlength: z.number().optional(),
|
|
17
|
+
invalid: z.boolean().default(false),
|
|
18
|
+
describedby: z.string().optional(),
|
|
19
|
+
}),
|
|
20
|
+
events: ['input'],
|
|
21
|
+
render: ({ props, on }) =>
|
|
22
|
+
ui.input({
|
|
23
|
+
id: props.id,
|
|
24
|
+
name: props.name,
|
|
25
|
+
value: props.value,
|
|
26
|
+
required: props.required,
|
|
27
|
+
minlength: props.minlength,
|
|
28
|
+
maxlength: props.maxlength,
|
|
29
|
+
'aria-invalid': props.invalid,
|
|
30
|
+
'aria-describedby': props.describedby,
|
|
31
|
+
on: { input: on.input },
|
|
32
|
+
}),
|
|
33
|
+
})
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ui } from '@hozu/core'
|
|
2
|
+
import * as badge from './badge.ts'
|
|
3
|
+
import * as button from './button.ts'
|
|
4
|
+
import * as field from './field.ts'
|
|
5
|
+
import * as input from './input.ts'
|
|
6
|
+
|
|
7
|
+
export const kit = ui.kit({ id: 'ui', components: [button, input, field, badge] })
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Sign-in, sessions, who may read and change what
|
|
2
|
+
|
|
3
|
+
- **Start:** `hozu add feature notes --page / --with auth` (sign-in page, sign-out, `me`); set `SESSION_SECRET`.
|
|
4
|
+
- `project({ session: z.object({ user: z.string() }) })`. `scope: 'user'` queries and mutations get `session`;
|
|
5
|
+
a mutation calls `setSession(value)` (`null` signs out).
|
|
6
|
+
- **Every server-run user query and mutation says who may run it**, like `runs` (HZ088):
|
|
7
|
+
- `access: 'signedIn'`: any signed-in visitor; the resolver reads that visitor's data by `session`.
|
|
8
|
+
- `access: { owner: { row: (n) => n.owner, session: (s) => s.user } }`: the framework checks the output. One row
|
|
9
|
+
that is not the visitor's is `Forbidden` (a missing owner on either side never matches); a list holding such
|
|
10
|
+
rows is HZ091 (the resolver read too much).
|
|
11
|
+
- On a mutation, `{ owner: { load: getNote, input: (i) => ({ id: i.id }), row: (n) => n.owner, session: (s) =>
|
|
12
|
+
s.user } }` reads the row and checks it before the resolver runs; if `load` fails for any reason (`NotFound`
|
|
13
|
+
too), the answer is `Forbidden` and the resolver does not run.
|
|
14
|
+
- `access: { allow: ({ session, input }) => session.role === 'admin' }`.
|
|
15
|
+
- `access: 'anyone'`: sign-in, a newsletter. On user data it is HZ090.
|
|
16
|
+
- **Refused** is the framework error `Forbidden`, before the resolver runs: optional in `failed` (otherwise
|
|
17
|
+
`Unexpected`). A page answers 403, or maps it: `head: { query: me, …, failed: { Forbidden: login } }`.
|
|
18
|
+
- Check it as two visitors: `hozu call <effect> --session '{"user":"bob"}'`, or in one chain: `hozu browse /
|
|
19
|
+
--as ada --session '{"user":"ada"}' --do 'remember note from li a @href' --as bob --session '{"user":"bob"}'
|
|
20
|
+
--do 'goto $note'` (bob gets 403). `post <path> a=1` forges a native post as the current actor.
|
|
21
|
+
|
|
22
|
+
<!-- more -->
|
|
23
|
+
|
|
24
|
+
## Details
|
|
25
|
+
- The scaffold writes `features/account` (sign-in page, sign-out, `me`), per-user resolvers, and a redirect to
|
|
26
|
+
`/login` when signed out. Replace the name-only sign-in with real credentials before production; production
|
|
27
|
+
without `SESSION_SECRET` refuses to start.
|
|
28
|
+
- Public queries never receive `session`.
|
|
29
|
+
- Sessions live on the server: the default store is `memorySessions()` (from `@hozu/runtime-server`); the cookie holds
|
|
30
|
+
only an opaque, signed, HttpOnly id, so `setSession(null)` revokes it and the session never reaches browser
|
|
31
|
+
JavaScript. It is per process: a restart signs everyone out, and an edge or multi-instance deployment passes a
|
|
32
|
+
shared store explicitly (`createHandler({ session })`).
|
|
33
|
+
- `setSession` also applies in a failing mutation (expiry: `setSession(null)` then `fail('Expired', …)`).
|
|
34
|
+
- After a sign-in or sign-out the page's queries are re-read with the new session; nothing from the old one stays.
|
|
35
|
+
- A role on top of sign-in: `'signedIn'` plus a declared error (`NotAdmin`) mapped to 403 keeps "signed out → login"
|
|
36
|
+
apart from "not allowed → 403" (`failed: { Forbidden: login, NotAdmin: 403 }`); `{ allow }` answers Forbidden for both.
|
|
37
|
+
- `access` is recorded in `hozu.lock.json`, so a change to it is reviewed like a transition.
|
|
38
|
+
- `owner` needs the row to carry its owner field (HZ088 otherwise): add it to the output, or use `'signedIn'` and
|
|
39
|
+
read only the visitor's rows. In production a list's foreign rows are dropped and logged once.
|
|
40
|
+
- Public queries never see the session, and a browser-run effect is guarded by the API it calls: `access` there is
|
|
41
|
+
HZ089.
|
|
42
|
+
- **A token that expires:** keep it and its expiry in the session, and refresh it in `app({ refreshSession: async
|
|
43
|
+
(session, { env }) => session.expires > Date.now() ? undefined : { ...session, token: await renew(session) } })`.
|
|
44
|
+
It runs once per request before any resolver reads the session (queries stay read-only); the new value replaces
|
|
45
|
+
the old one in place (the cookie stays), `null` signs out, `undefined` keeps it. Requests of one session in one process share one call
|
|
46
|
+
(and its result for ten seconds); a sign-out while it runs wins; a throw keeps the session and reaches `onError`. Do not keep tokens in module variables: a restart or a second
|
|
47
|
+
instance loses them.
|
|
48
|
+
- Calling another API with a token: a token your server holds goes in the session and is read in a `runs: 'server'`
|
|
49
|
+
resolver; a token that lives in the browser (OIDC / SSO, `localStorage`) is read in a `runs: 'browser'` effect
|
|
50
|
+
(`hozu docs fetch`) and never reaches your server.
|
|
51
|
+
- `SESSION_SECRET` is at least 32 characters: `openssl rand -hex 32`. Keep it in a git-ignored `.env` and list
|
|
52
|
+
it in `.env.example` (`npx hozu env --example`).
|
|
53
|
+
- **A refused page** renders the page's views with status 403 (the `<title>` falls back to the site name). For a
|
|
54
|
+
page of its own, map `Forbidden` (or a declared error) to a route: `failed: { Forbidden: login }`.
|
|
55
|
+
- **Order on a mutation:** the input schema first (`Invalid` with field errors), then access (`Forbidden`), then the
|
|
56
|
+
resolver.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Components (shared UI, kits, browser code)
|
|
2
|
+
|
|
3
|
+
A UI piece used by several features is a component in a kit: `hozu add kit ui`, then `hozu add component ui Button`
|
|
4
|
+
(`hozu add component <feature> <Name>` makes one private to that feature).
|
|
5
|
+
```ts
|
|
6
|
+
// ui/button.ts; ui/kit.ts lists it: ui.kit({ id: 'ui', components: [button, input] })
|
|
7
|
+
const styles = tv({ // tv from ./tv.ts
|
|
8
|
+
base: 'inline-flex gap-2 rounded px-4 py-2 disabled:opacity-50 aria-busy:cursor-wait',
|
|
9
|
+
variants: { tone: { primary: 'bg-indigo-600 text-white', ghost: 'text-slate-700 hover:bg-slate-100' } },
|
|
10
|
+
defaultVariants: { tone: 'primary' },
|
|
11
|
+
})
|
|
12
|
+
export const Button = ui.component({
|
|
13
|
+
tag: 'button', styles, slots: ['icon'], children: true, events: ['press'],
|
|
14
|
+
props: z.object({ type: z.enum(['button', 'submit']).default('button'), busy: z.boolean().default(false) }),
|
|
15
|
+
render: ({ props, slots, children, on }) =>
|
|
16
|
+
ui.button({ type: props.type, 'aria-busy': props.busy, on: { click: on.press } }, [slots.icon, ...children]),
|
|
17
|
+
})
|
|
18
|
+
```
|
|
19
|
+
```ts
|
|
20
|
+
ui.use(Button, { variant: { tone: 'ghost' }, props: { busy: ctx.saving }, slots: { icon: ui.span({}, ['+']) },
|
|
21
|
+
on: { press: ui.send(Save, {}) }, class: 'w-full' }, ['Save']) // in a view; its id is ui.Button
|
|
22
|
+
```
|
|
23
|
+
- **`ui.use` keys** (all optional): `variant` (literals only, HZ071), `props` (anything that changes while the page
|
|
24
|
+
runs), `slots`, `on`, `class`; children only with `children: true`.
|
|
25
|
+
- **Render** reads only `props`, `slots`, `children`, `on` and `classes`; the caller passes sends, links and text
|
|
26
|
+
in (HZ070).
|
|
27
|
+
- **`class`** may only add classes that set none of the component's properties (`w-full`, `md:hidden`); to change
|
|
28
|
+
one, declare a variant (HZ072–HZ077; one-offs: see --more).
|
|
29
|
+
- Browser APIs or DOM libraries: a client component, `hozu add component <kit|feature> <Name> --client` (see --more).
|
|
30
|
+
- `previews.ts` (`project({ previews })`) is for people: named component states and page screens in DevTools
|
|
31
|
+
Assets. It never ships; change it only when asked or when HZ092 names a line (see --more).
|
|
32
|
+
|
|
33
|
+
<!-- more -->
|
|
34
|
+
|
|
35
|
+
## Details
|
|
36
|
+
- `hozu add kit ui` writes `ui/kit.ts`, `ui/tv.ts` and `project({ kits })`.
|
|
37
|
+
- **Variants vs props:** a variant is a fixed look chosen in the view (HZ071 for data); anything that changes while
|
|
38
|
+
the page runs is a prop. Style a state through the attribute that announces it: `disabled:`, `aria-pressed:`,
|
|
39
|
+
`aria-busy:`, `aria-invalid:`, `aria-expanded:`, `open:`. `toggle` is for states without one.
|
|
40
|
+
- **Render:** `classes` holds the other tv slots (`classes.label`). Hozu puts the root class on the root.
|
|
41
|
+
- **Extension:** `class` may add classes that set none of the component's properties (`w-full`, `relative`,
|
|
42
|
+
`md:hidden`). To change one, declare a variant; a one-off ends with `!` (`rounded-lg!`) (HZ072–HZ077).
|
|
43
|
+
`hozu check` counts the `!` per component; `extend: false` refuses every class.
|
|
44
|
+
- A view fragment that two features inline is a component, not a `part()` (HZ080).
|
|
45
|
+
|
|
46
|
+
## Client components (browser APIs, DOM libraries)
|
|
47
|
+
`hozu add component <kit|feature> <Name> --client` writes the declaration, the client module, the bundle in `app.ts`
|
|
48
|
+
and the `@hozu/bundle` dependency.
|
|
49
|
+
```ts
|
|
50
|
+
export const Map = ui.component({ tag: 'div', props: z.object({ lat: z.number(), lng: z.number() }),
|
|
51
|
+
emits: { picked: z.object({ id: z.string() }) }, client: new URL('./map.client.ts', import.meta.url),
|
|
52
|
+
load: 'visible', render: () => ui.div({}, []) }) // 'eager' | 'visible' | 'idle'
|
|
53
|
+
ui.use(Map, { props: { lat: ctx.lat, lng: ctx.lng }, on: { picked: (d) => ui.send(Pick, { id: d.id }) },
|
|
54
|
+
class: 'h-96 w-full' })
|
|
55
|
+
```
|
|
56
|
+
```ts
|
|
57
|
+
// map.client.ts: a type-only import of the declaration
|
|
58
|
+
import { implement } from '@hozu/core/component'
|
|
59
|
+
import type { Map } from './components.ts'
|
|
60
|
+
export default implement<typeof Map>(({ el, props, emit, signal }) => {
|
|
61
|
+
const map = createMap(el, props) // any DOM library
|
|
62
|
+
map.on('pick', (id) => emit('picked', { id }))
|
|
63
|
+
return { update(next) { map.move(next) }, destroy() { map.remove() } }
|
|
64
|
+
})
|
|
65
|
+
```
|
|
66
|
+
- The render is the server HTML the module takes over; with `children: true` the children stay as the no-JS
|
|
67
|
+
fallback. The root takes no attributes or `on`: put a role or label on a wrapping element.
|
|
68
|
+
- `app.ts` passes `components: bundleComponents` (`@hozu/bundle`) to `app()` (HZ045 without it). A library's CSS
|
|
69
|
+
goes in `app.css`; a map or chart host needs a height class.
|
|
70
|
+
- `hozu browse /` lists each one as mounted / failed / not mounted with its size and canvases; a mounted host has
|
|
71
|
+
`data-hozu-component="<id>"` and `data-hozu-component-state="mounted"`.
|
|
72
|
+
|
|
73
|
+
## Look them up
|
|
74
|
+
- `hozu docs components` (this topic, then the app's list), `hozu inspect ui.Button` (variants, props, owned
|
|
75
|
+
classes, every use), `hozu why ui.Button`.
|
|
76
|
+
- `hozu render ui.Button --variant tone=ghost --props '{"busy":true}' --slot icon=+` renders it alone: HTML, root
|
|
77
|
+
class, owned properties, diagnostics.
|
|
78
|
+
|
|
79
|
+
## Previews (for people, never shipped)
|
|
80
|
+
- `project({ previews: new URL('./previews.ts', import.meta.url) })`; only `hozu dev` and `hozu check` load it. DevTools **Assets** shows every component × variant (props from the schema) plus these.
|
|
81
|
+
- ```ts
|
|
82
|
+
import { previews } from '@hozu/core/preview'
|
|
83
|
+
export default previews((p) => [
|
|
84
|
+
p.component(Button, 'Long label', { variant: { tone: 'primary' }, children: 'Save every note' }),
|
|
85
|
+
p.page(home, 'No notes', [p.data(me, { name: 'ada' }), p.data(listNotes, [])]),
|
|
86
|
+
p.page(home, 'Failed', [p.fail(listNotes, 'Unexpected')]),
|
|
87
|
+
])
|
|
88
|
+
```
|
|
89
|
+
- A page preview answers those queries under `hozu dev` only (Layers → Previews, or Assets → Screens), on every
|
|
90
|
+
page while it is on; other queries and mutations run as usual. HZ092: data off the output schema, an undeclared error, a route without a page, a use that
|
|
91
|
+
does not build.
|
|
92
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Markdown, images, share images, fonts, preview, offline
|
|
2
|
+
|
|
3
|
+
- **Markdown:** `npm install @hozu/content` (not in the scaffold), then in `app.ts`
|
|
4
|
+
`const posts = await loadCollection({ dir: new URL('./content/posts/', import.meta.url), schema })`: one
|
|
5
|
+
`{ slug, data, html, headings }` per `.md` file. Return them from query resolvers and render `ui.html(post.html)`.
|
|
6
|
+
- **Images:** `ui.img({ src: ui.asset(new URL('./hero.jpg', import.meta.url)), alt, width, height })` (HZ028 without
|
|
7
|
+
dimensions).
|
|
8
|
+
|
|
9
|
+
<!-- more -->
|
|
10
|
+
|
|
11
|
+
- **Collections in detail:**
|
|
12
|
+
- `slug` is the file name without `.md`.
|
|
13
|
+
- Only the folder's own files are read, not sub-folders; entries come in file-name order (sort them in the
|
|
14
|
+
resolver).
|
|
15
|
+
- `data` is the front matter parsed as YAML 1.2 and checked against `schema`. An unquoted date such as
|
|
16
|
+
`2026-09-12` stays a string, so `z.iso.date()` fits.
|
|
17
|
+
- A front matter error or a schema mismatch throws with the file name at startup.
|
|
18
|
+
- There are no conventional fields: a draft flag or an excerpt is a field in your schema.
|
|
19
|
+
- `html` is GFM; headings get ids, listed in `headings`.
|
|
20
|
+
- Style it with your own CSS for the container (the `prose` class needs the Tailwind typography plugin in
|
|
21
|
+
`app.css`, otherwise HZ026).
|
|
22
|
+
|
|
23
|
+
- **Images:** with `@hozu/image`, `hozu build` adds WebP `srcset` widths.
|
|
24
|
+
- **Share images:** `head.render → image: ui.og({ title, subtitle })` (needs `app({ og: ogImage })` with `ogImage`
|
|
25
|
+
from `@hozu/image`); on a static host use `image: ui.asset(new URL('./share.png', import.meta.url))`.
|
|
26
|
+
- **Fonts:** prefer local font files: a local `@font-face` gets a size-matched fallback automatically. A remote font
|
|
27
|
+
is `@import url('https://fonts.googleapis.com/css2?family=…');` in `app.css` (moved to the top of the output) plus
|
|
28
|
+
its CSP sources: `app({ csp: { style: ['https://fonts.googleapis.com'], font: ['https://fonts.gstatic.com'] } })`.
|
|
29
|
+
- **Page transitions:** links cross-fade (CSS view transitions, no JS); turn off with
|
|
30
|
+
`@view-transition { navigation: none; }` in `app.css`.
|
|
31
|
+
- **Preview:** `app({ preview: { secret } })`; `/_hozu/preview?secret=…&path=/posts/a` turns it on; resolvers
|
|
32
|
+
read `ctx.preview`; preview responses are never cached.
|
|
33
|
+
- **Offline:** `site.offline: route` (a static page) makes a service worker (HZ043).
|
|
34
|
+
- **One collection per language:** a folder per locale (`content/en`, `content/de`), loaded by the query for the
|
|
35
|
+
page's locale (`head.input`'s second argument).
|
|
36
|
+
- **Images named in front matter** (`cover: night.png`): serve them with a GET endpoint (`output: 'response'`) that
|
|
37
|
+
reads only the files some entry lists. There is no `public/` folder served at the root.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Contracts
|
|
2
|
+
|
|
3
|
+
A transition that **decides** (a guard, a `navigate`, a `fn()`, a comparison or a computing operator in its values)
|
|
4
|
+
needs a contract; HZ016 prints each missing one, ready to paste:
|
|
5
|
+
`contract(m, { given: { state }, when: [{ send: Event, payload }], expect: { state, changes, effects } })`
|
|
6
|
+
(full example: see --more). Export it from `views.ts`. After a behaviour change, run `hozu check --update-lock`
|
|
7
|
+
and list the accepted `now:` lines in your summary. When a contract fails (HZ015), decide which is intended before
|
|
8
|
+
changing either.
|
|
9
|
+
|
|
10
|
+
<!-- more -->
|
|
11
|
+
|
|
12
|
+
- Computing operators: `+ - ?? ?: .length .includes`. Transitions that only copy values need no contract (a contract
|
|
13
|
+
there is HZ058).
|
|
14
|
+
- `hozu.lock.json` records every transition readably and must equal the computed lock: any difference is HZ057
|
|
15
|
+
until `hozu check --update-lock` accepts it.
|
|
16
|
+
- A deciding change also needs a contract that fails against the old behaviour (HZ018); renaming or copying a
|
|
17
|
+
contract does not count.
|
|
18
|
+
- A transition that stops deciding (its guard or `navigate` removed) needs only the lock: `hozu check
|
|
19
|
+
--update-lock`, then delete the contracts HZ058 names.
|
|
20
|
+
- A transition to `'previous'` needs the state it returns to: `given: { state: 'adding', previous: 'editing' }`.
|
|
21
|
+
- Contracts may be exported from any module the feature lists. When one fails, the choice is between the machine
|
|
22
|
+
and the contract.
|
|
23
|
+
```ts
|
|
24
|
+
export const addsValid = contract(m, {
|
|
25
|
+
given: { state: 'idle' }, // context: initialContext; { touring: true } overrides fields
|
|
26
|
+
when: [
|
|
27
|
+
{ send: Add, payload: { title: 'Milk' } },
|
|
28
|
+
{ done: addItem, result: { id: 'i9', title: 'Milk', done: false } },
|
|
29
|
+
], // or { failed: addItem, error: 'Duplicate', data } / { elapse: ms }
|
|
30
|
+
expect: {
|
|
31
|
+
state: 'idle',
|
|
32
|
+
changes: { draft: '' }, // only what changes; nested objects are patches
|
|
33
|
+
effects: [{ effect: addItem, input: { title: 'Milk' } }, { navigate: '/items/i9' }], // default: none
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
```
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Data: queries, mutations, tags, fn, resolvers
|
|
2
|
+
|
|
3
|
+
**First: whose data is it?** It decides `runs`, `scope` and where it is stored. When the request does not say, ask.
|
|
4
|
+
|
|
5
|
+
| The data | `runs` / `scope` | Stored in |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| the visitor's own, no sign-in (a watchlist, favourites, settings) | `'browser'` / `'user'` | `localStorage`, in `fetch.ts` (`hozu docs recipes`) |
|
|
8
|
+
| a signed-in user's, on every device | `'server'` / `'user'` + `access` | the app's database |
|
|
9
|
+
| everyone's (posts, a shared board) | `'server'` / `'public'` + a deliberate `access` | the app's database |
|
|
10
|
+
| a public third-party API (quotes, weather) | `'either'` / `'public'` | nowhere: read it |
|
|
11
|
+
|
|
12
|
+
The arrays in Hozu's examples and scaffolds are stand-ins that keep them short: one list for every visitor, gone on
|
|
13
|
+
restart. Never ship one; replace it with the store above.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
export const itemsTag = tag({ param: null }) // tag({ param: z.string() }) → itemTag(id)
|
|
17
|
+
export const listItems = query({
|
|
18
|
+
input: z.object({}), output: z.array(Item),
|
|
19
|
+
scope: 'public', // 'user' = the session's data (needs project({ session }))
|
|
20
|
+
freshness: 'static', // | 'request' | { revalidate: s } | { swr: s } | 'live' | { poll: s }
|
|
21
|
+
tags: () => [itemsTag()], // optional; (input) => [...]
|
|
22
|
+
runs: 'server', // where the implementation lives: 'server' | 'browser' | 'either' (required); hozu docs fetch
|
|
23
|
+
})
|
|
24
|
+
export const getItem = query({ input: Key, output: Item, errors: { NotFound: Key }, scope: 'public',
|
|
25
|
+
freshness: 'static', tags: (k) => [itemTag(k.id)], runs: 'server' })
|
|
26
|
+
export const addItem = mutation({
|
|
27
|
+
input: z.object({ title: z.string().min(2, 'Use at least 2 characters') }), output: Item,
|
|
28
|
+
errors: { Duplicate: z.object({ title: z.string() }) }, // optional: declared failures
|
|
29
|
+
invalidates: () => [itemsTag()], // refreshes queries with these tags
|
|
30
|
+
runs: 'server',
|
|
31
|
+
access: 'anyone', // who may run it (required on the server; user queries too): hozu docs auth
|
|
32
|
+
})
|
|
33
|
+
export const visible = fn({ // computation: pure JS; may call const/function helpers of this module
|
|
34
|
+
input: z.object({ items: z.array(Item), show: Show }), output: z.array(Item),
|
|
35
|
+
impl: ({ items, show }) => items.filter((i) => show === 'all' || !i.done),
|
|
36
|
+
})
|
|
37
|
+
```
|
|
38
|
+
```ts
|
|
39
|
+
export default app({ resolvers: resolvers(project, (implement) => [
|
|
40
|
+
implement(listItems, () => db.items.list()), // db: the app's database client
|
|
41
|
+
implement(getItem, async ({ id }, { fail }) => (await db.items.get(id)) ?? fail('NotFound', { id })),
|
|
42
|
+
implement(addItem, ({ title }, { fail, session }) => /* … */ ),
|
|
43
|
+
]) })
|
|
44
|
+
```
|
|
45
|
+
- `runs` is required on every query and mutation. `'server'` resolvers live in `app.ts` (or `features/<name>/server.ts`)
|
|
46
|
+
and get the schema-parsed input; `'browser'` / `'either'` live in `fetch.ts` (`hozu docs fetch`).
|
|
47
|
+
- **Query resolvers only read;** writes happen in mutation and endpoint resolvers (see --more).
|
|
48
|
+
- User data (`scope: 'user'`) is `freshness: 'request'`, `'live'` or `{ poll }` only (HZ049); `'live'` needs tags (HZ050).
|
|
49
|
+
- **Changes on its own** (quotes, a feed): `freshness: { poll: 30 }` reads it again every 30 s (5 to 86400) while a page
|
|
50
|
+
shows it, also from the browser. `'live'` is for data your own mutations change.
|
|
51
|
+
- Call a `fn` from views or machines: `ui.each(visible({ items, show: ctx.show }), 'id', …)`.
|
|
52
|
+
|
|
53
|
+
<!-- more -->
|
|
54
|
+
|
|
55
|
+
## Details
|
|
56
|
+
- A `fn` body may call functions and JSON constants declared in the same module; they are sent to the browser with
|
|
57
|
+
it. Imported names and `let` state are not (HZ047): pass them as input.
|
|
58
|
+
- Rendering is derived: `scope` and `freshness` decide static, ISR, SWR, streamed or client rendering;
|
|
59
|
+
`scope: 'user'` data never reaches a cached page (HZ022). A mutation's tags can read only its input.
|
|
60
|
+
- Freshness describes how the data changes, not where it is read; choose it per query. `'static'` (with tags) is
|
|
61
|
+
for data only your own declared writers change; `'request'` reads every time it is needed (on the server per
|
|
62
|
+
request, in the browser on mount and on tags; a public one makes its page uncacheable). `'live'` is only for push
|
|
63
|
+
updates.
|
|
64
|
+
- `invalidates` drives the refresh: after a mutation or endpoint, cached pages and entries with those tags are
|
|
65
|
+
dropped and the page's queries with those tags are re-read. `endpoint({ …, invalidates: (input) => [tag()] })`
|
|
66
|
+
applies when it succeeds (use POST; a GET write is HZ062).
|
|
67
|
+
- Writes from outside (a webhook, a job): `await server.revalidate([itemsTag()])` → `{ entries, pages }`.
|
|
68
|
+
- **Why queries only read:** a query that creates a row on read runs again on every request, on prefetch and after a
|
|
69
|
+
delete (the account comes back). Keep two helpers: `listOf(user)` returns the stored list or `[]` for queries;
|
|
70
|
+
`ownListOf(user)` creates it, for mutations only.
|
|
71
|
+
- Resolvers in `features/<name>/server.ts` (from the scaffold) are spread into `app.ts`; the input they get has
|
|
72
|
+
defaults and transforms applied.
|
|
73
|
+
- Every mutation also has `Invalid` = `{ message, fields }` (input failing its schema, or
|
|
74
|
+
`fail('Invalid', { message, fields: { title: 'Taken' } })`); never declare `Invalid` or `Unexpected` yourself.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Deployment
|
|
2
|
+
|
|
3
|
+
- **One app module:** `project({ app: new URL('./app.ts', import.meta.url) })`; `app.ts` default-exports
|
|
4
|
+
`app({ resolvers, session?, components?, … })` from `@hozu/runtime-server`. `hozu serve`, `hozu check`, `hozu get`,
|
|
5
|
+
`hozu browse` and `testApp(app)` all run it, so what the tools verify is what production serves.
|
|
6
|
+
- **Which host:** nobody marks pages static. `npx hozu export` writes every page for a static host (GitHub Pages,
|
|
7
|
+
Netlify, Cloudflare Pages, Vercel) to `dist/` and exits 1 naming each page or effect that needs a server; then
|
|
8
|
+
use Node (`npm start`, a Docker image) or Cloudflare Workers.
|
|
9
|
+
- **Node:** `npm start` is `hozu serve` (adapter-node on `PORT`, `HOST`). Docker: `node:22-slim`, `npm ci
|
|
10
|
+
--omit=dev`, `CMD ["npx", "hozu", "serve"]`.
|
|
11
|
+
- Set `SESSION_SECRET` when the app has sessions: `npm start` (`hozu serve`) runs as production unless `NODE_ENV` is set, and production refuses to start without it. `hozu dev`, `get`, `browse` and `call` do not need it.
|
|
12
|
+
- Workers, several instances, sessions in KV, upgrading: see --more.
|
|
13
|
+
|
|
14
|
+
<!-- more -->
|
|
15
|
+
|
|
16
|
+
## Details
|
|
17
|
+
- **App options:** `app({ resolvers: resolvers(project, (implement) => [...]), session?, components?, og?, csp?,
|
|
18
|
+
onError?, preview? })`. There is no wrapper position: headers go through `project({ http })`, statuses through
|
|
19
|
+
`head.failed` and endpoint `failed`, the language through the URL.
|
|
20
|
+
- **Node:** adapter-node serves `process.env`, styles and every `ui.asset` (hashed under `/_hozu/a/`). There is no
|
|
21
|
+
`public/` folder served at the root: a file the page shows is a `ui.asset(new URL(...))`; a file named in data
|
|
22
|
+
(a cover in front matter) is served by a GET endpoint with `output: 'response'`. `hozu build` writes
|
|
23
|
+
`dist/public/`, `dist/manifest.json` and `dist/server/render.js`. It compresses answers as they stream (gzip),
|
|
24
|
+
and framework files with brotli or gzip from the `.br` / `.gz` that `hozu build` writes. Live streams are not
|
|
25
|
+
compressed. The edge handler leaves compression to the platform.
|
|
26
|
+
- **Edge (Cloudflare Workers, Bun, Deno):** `hozu build --out build` (git-ignore `build/`), then bundle an entry with esbuild and
|
|
27
|
+
`hozuTransform()` from `@hozu/transform/esbuild` (it gives each app file its own `import.meta.url`, which a
|
|
28
|
+
Worker lacks). The entry creates the handler on the first request, when the platform's `env` is known:
|
|
29
|
+
`handler ??= createHandler(app, { manifest, render, env })` from `@hozu/runtime-server`, with
|
|
30
|
+
`import * as render from './build/server/render.js'`. Serve `build/public` as static assets (wrangler
|
|
31
|
+
`[assets] directory`). Workers keep no memory between requests: data goes in a database.
|
|
32
|
+
- **Static host:** `npx hozu export [--out dist]` (`@hozu/adapter-static`, in new apps) empties the folder, writes
|
|
33
|
+
every page without per-request server data plus `.nojekyll`, and prints what it skipped. Pages whose data runs in
|
|
34
|
+
the browser (`runs: 'browser'` / `'either'`) export completely; a page that calls a server effect is listed
|
|
35
|
+
(HZ082). A GitHub project site sets `project({ http: { basePath: '/<repo>' } })` and uploads `dist/<repo>`.
|
|
36
|
+
In code: `exportStatic({ build, styles, resolvers, outDir })`.
|
|
37
|
+
- **Edge and fetch.ts:** a host without `import()` of files passes `createHandler(app, { fetches: async (f) =>
|
|
38
|
+
modules[f] })` for `runs: 'either'` effects.
|
|
39
|
+
- **Sessions:** the default store keeps sessions in memory per process. Several instances or Workers share
|
|
40
|
+
`kvSessions(kv, { secret })` (`@hozu/runtime-server`; `kv` has Cloudflare KV's `get` / `put(key, value,
|
|
41
|
+
{ expirationTtl })` / `delete`, so a KV binding fits as is; wrap Redis in those three). On Workers pass it to the
|
|
42
|
+
handler: `createHandler(app, { manifest, render, env, session: kvSessions(env.SESSIONS, { secret:
|
|
43
|
+
env.SESSION_SECRET }) })`. Cloudflare KV may take up to a minute to show a sign-out in other regions.
|
|
44
|
+
- **Another store:** implement `SessionStore` (`read`, `write`, `issue`) and pass it as `app({ session })` or the
|
|
45
|
+
handler's `session`; keep the cookie an opaque signed id (ADR 0043 B).
|
|
46
|
+
|
|
47
|
+
## Caches and many instances
|
|
48
|
+
- **Bounded caches:** public query results and cached pages are LRU caches, at most 10,000 entries and 5,000 pages
|
|
49
|
+
per process. Change the bounds with `app({ dataCache: memoryDataCache({ maxEntries }) })` (`@hozu/data`) and
|
|
50
|
+
`app({ cache: memoryCache({ maxPages }) })` (`@hozu/runtime-server`). `server.stats()` reports
|
|
51
|
+
`{ dataEntries, pages, evictions }`.
|
|
52
|
+
- **More than one instance:** each instance caches on its own, so a mutation on one must tell the others. Pass
|
|
53
|
+
`app({ bus: httpBus({ peers: [every instance's base URL, this one included], secret: process.env.BUS_SECRET }) })`
|
|
54
|
+
(`@hozu/runtime-server`; signed `POST /_hozu/invalidate`, 32+ character secret). The others drop the tagged pages
|
|
55
|
+
and data and push to their live clients. Messages carry tags, never data.
|
|
56
|
+
- **A broker instead of peer URLs:** implement `InvalidationBus` (`publish(tags)`, `subscribe(onTags)`):
|
|
57
|
+
```ts
|
|
58
|
+
const pub = createClient({ url }); const sub = pub.duplicate() // e.g. redis
|
|
59
|
+
await Promise.all([pub.connect(), sub.connect()])
|
|
60
|
+
const bus: InvalidationBus = {
|
|
61
|
+
publish: (tags) => void pub.publish('hozu', JSON.stringify(tags)),
|
|
62
|
+
subscribe: (onTags) => {
|
|
63
|
+
void sub.subscribe('hozu', (m) => onTags(JSON.parse(m)))
|
|
64
|
+
return () => void sub.unsubscribe('hozu')
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
- `app({ staticTtl: 300 })` re-reads `'static'` data and pages after 300 s, in case a bus message is lost; off by
|
|
69
|
+
default.
|
|
70
|
+
|
|
71
|
+
## Upgrading Hozu
|
|
72
|
+
`npx -p @hozu/cli@latest hozu migrate` (preview with `--dry-run`), then run the `next:` lines it prints: install, and
|
|
73
|
+
`npx hozu migrate` again, which checks the IR is unchanged and runs `hozu check`. Never raise `@hozu/*` by hand.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Hozu diagnostics
|
|
2
|
+
|
|
3
|
+
Every diagnostic carries `file:line`, a cause and a fix, and often a snippet or patch. Apply the fix; do not work
|
|
4
|
+
around the rule. `npx hozu docs HZ083` prints one code: its cause, its fix and the topic to read.
|
|
5
|
+
|
|
6
|
+
- Errors fail `hozu check`. Warnings (HZ010, HZ019, HZ025, HZ036, HZ056, HZ058, HZ061, HZ062, HZ063, HZ075, HZ076, HZ077, HZ080, HZ083, HZ084, HZ086, HZ087, HZ089, HZ090) do not, but each one names something to decide.
|
|
7
|
+
- A warning you keep on purpose goes in `project({ accept: [{ code, at, reason }] })`; errors cannot be accepted.
|
|
8
|
+
|
|
9
|
+
<!-- more -->
|
|
10
|
+
|
|
11
|
+
| Code | Meaning | Usual fix |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| HZ001 | state unreachable | add a transition to it or delete it |
|
|
14
|
+
| HZ002 | event handled nowhere | handle it in a state or remove it |
|
|
15
|
+
| HZ003 | unknown effect / reference | export it from a module the feature lists in `declarations`, or fix the name (the patch suggests one) |
|
|
16
|
+
| HZ004 | a declared error is not handled | add every `failed` key, plus `Unexpected`, in `invoke` and `ui.query` |
|
|
17
|
+
| HZ005 | a node sends an event in a state (without `invoke`) that does not handle it | `ignore: [Event]` in that state, or show the node only via `when` |
|
|
18
|
+
| HZ006 | crossing a feature boundary | import the feature and use its `exports` |
|
|
19
|
+
| HZ007 | unknown effect / reference | export it from a module the feature lists in `declarations`, or fix the name (the patch suggests one) |
|
|
20
|
+
| HZ008 | a path does not exist in the schema | fix the property name |
|
|
21
|
+
| HZ009 | a guardless transition shadows later ones | put guarded transitions first |
|
|
22
|
+
| HZ010 (warning) | a state has no way out (not final; no `on`, `invoke` or `after`) | mark it `final: true` or add a transition out of it |
|
|
23
|
+
| HZ011 | building twice gave different IR | keep time, randomness and mutable state out of builder callbacks |
|
|
24
|
+
| HZ012 | a schema from another library than the project's adapter | write it with the project's schema library (`project({ schema })`) |
|
|
25
|
+
| HZ013 | a declaration registered twice: a second machine (or messages) in a feature, or a name another feature already declares | keep one; reach the other feature through `imports` and its `exports` |
|
|
26
|
+
| HZ014 | wrong builder output, or a method called on data (`.map`, `.toUpperCase()`) | follow the builder signature; lists: `ui.each`; computation: a `fn()` |
|
|
27
|
+
| HZ015 | a contract fails / contract data does not match its schema | fix the machine or the contract (decide the intended behaviour first) |
|
|
28
|
+
| HZ016 | a transition that decides (guard, `navigate`, a `fn`, comparison or `+ - ?? ?: .length .includes` in a value) has no contract | add the contract from the snippet |
|
|
29
|
+
| HZ017 | a contract fails / contract data does not match its schema | fix the machine or the contract (decide the intended behaviour first) |
|
|
30
|
+
| HZ018 | a deciding transition changed (fields first, then `was:` / `now:`) and no covering contract fails against the old behaviour | change or add a contract that specifies the new behaviour; renaming or copying one does not count |
|
|
31
|
+
| HZ019 (warning) | `invalidates` names a tag no query carries | tag the affected query, or remove the invalidation |
|
|
32
|
+
| HZ020 | user-scoped data, but the project declares no session | declare `project({ session })`, or make the query public |
|
|
33
|
+
| HZ021 | a query, mutation or endpoint without a resolver | `implement(...)` it in the resolvers of `app.ts` |
|
|
34
|
+
| HZ022 | user data in a cacheable region | keep `scope: 'user'` queries out of cached pages |
|
|
35
|
+
| HZ023 | a page's `assert` does not hold for its derived render plan | change the data's scope or freshness, or the assertion |
|
|
36
|
+
| HZ024 | route params mismatch (keys, or a schema that does not fit `:x?`/`:x+`/`:x*`) / page with params but no `entries` (a user-scoped head is private: no sitemap, no warning) | align them / add `entries` |
|
|
37
|
+
| HZ025 (warning) | route params mismatch (keys, or a schema that does not fit `:x?`/`:x+`/`:x*`) / page with params but no `entries` (a user-scoped head is private: no sitemap, no warning) | align them / add `entries` |
|
|
38
|
+
| HZ026 | a class produces no CSS | fix the Tailwind class |
|
|
39
|
+
| HZ027 | a DOM field used outside an event, or wrong for this event | read `ui.dom.*` only in `ui.send` payloads |
|
|
40
|
+
| HZ028 | `img` without width/height | add both |
|
|
41
|
+
| HZ029 | a client component's module is missing, or a handler for an event it does not emit | create the module (`hozu add component … --client`); handle declared `emits` only |
|
|
42
|
+
| HZ030 | `ui.html` of untrusted data | render text instead |
|
|
43
|
+
| HZ031 | a literal not allowed by its schema | use an allowed value (the patch suggests one) |
|
|
44
|
+
| HZ032 | internal link or form action written as a string | `ui.link(route, params)` / `ui.link(endpoint)` |
|
|
45
|
+
| HZ033 | DOM text into an enum, number or boolean field | a `<select>`, radios or submit buttons with enum values; in a form, a flag through `ui.dom.formAll` and numbers parsed in the mutation input |
|
|
46
|
+
| HZ034 | a state both handles and ignores an event | remove it from one of the two |
|
|
47
|
+
| HZ035 | search schema is not a flat object of scalars with defaults | `z.object({ key: scalar.default(…) })` |
|
|
48
|
+
| HZ036 (warning) | a form needs JavaScript: it reads other DOM values, or starts a `runs: 'browser'` mutation | read its values with `ui.dom.form('name')` / `ui.dom.formAll('name')`; a browser mutation needs JS by design |
|
|
49
|
+
| HZ037 | a redirect is not a path, hides a page or another redirect, or targets an unknown route | change or remove the `from` key; point `to` at `ui.link(...)` |
|
|
50
|
+
| HZ038 | `http.headers` sets a header the framework owns, or an invalid name/value | remove it (`cache-control` is derived; CSP is `app({ csp })`) |
|
|
51
|
+
| HZ039 | `basePath` is not `''` or `/segment[/segment…]` | e.g. `'/shop'`, no trailing slash |
|
|
52
|
+
| HZ040 | a locale lacks a message, or uses other `{placeholders}` | add/translate the key in that locale |
|
|
53
|
+
| HZ041 | a machine uses a message, `ui.format` or `locale` | store a code in context; choose the message in the view |
|
|
54
|
+
| HZ042 | `site.locales` empty / missing `site.lang` / not a canonical tag, or `ui.alternate` of an undeclared locale | fix the list (`'zh-TW'`, not `'zh_tw'`) |
|
|
55
|
+
| HZ043 | `site.offline` has params, no page, or per-request data | point it at a static page, or remove `offline` |
|
|
56
|
+
| HZ044 | a feature file was loaded without the Hozu transform | run node with `--import @hozu/transform/register` (`npm start` does), or add `hozuTransform()` to Vite / Vitest |
|
|
57
|
+
| HZ045 | no `project({ app })`, its default export is not `app(…)`, or views use client components and `app()` has no bundle | `export default app({ resolvers, components: bundleComponents })` |
|
|
58
|
+
| HZ046 | an endpoint path is reserved, has params or collides; an error without a status, or with one an endpoint error cannot answer; a form posting to it with another method or an undeclared field | a static path such as `/api/…` (patch); map every error in `failed` to 400, 401, 403, 404, 409, 410, 422 or 429 |
|
|
59
|
+
| HZ047 | a `fn` body uses an imported name or `let` state (it is sent to the browser as source) | pass the value as input, or write it as a `const` helper in the module |
|
|
60
|
+
| HZ048 | `seed` names a field the context lacks, has no machine or route, or two views on one page seed a machine | seed top-level context fields, on one view per page |
|
|
61
|
+
| HZ049 | a `scope: 'user'` query is cached (`'static'`, `revalidate`, `swr`) | `freshness: 'request'` (patch), or `'live'` for push |
|
|
62
|
+
| HZ050 | a `'live'` query has no tags | add the tags its writers invalidate, or use `'request'` |
|
|
63
|
+
| HZ051 | `head.failed` misses a declared error of the head query, or maps another one | choose per error: a route (303), `403`, `404` or `410` (an intent decision: no patch) |
|
|
64
|
+
| HZ052 | a route that no page renders | link to the endpoint with `ui.link(endpoint, input)` (patch), or add its `ui.page` |
|
|
65
|
+
| HZ053 | (runtime) an endpoint answered `text/html`: a 500 | make it a `ui.page`; statuses and redirects go through `head.failed` |
|
|
66
|
+
| HZ054 | `ui.dom.form` reads one value of a list field or of a repeated name | `ui.dom.formAll('name')` (patch) |
|
|
67
|
+
| HZ055 | a form read names no control of the form | the name it suggests (patch), or add the control |
|
|
68
|
+
| HZ056 (warning) | a submit button also sends on click | `name`/`value` on the button, read in submit; or `type: 'button'` |
|
|
69
|
+
| HZ057 | `hozu.lock.json` differs from the computed lock (new, removed or copy-only changes, contract maps, a missing or 0.7 file) | if intended, `hozu check --update-lock`, then list the accepted `now:` lines in your summary |
|
|
70
|
+
| HZ058 (warning) | contracts that fire only copy-only transitions and evaluate no guard | none needed: the lock entries it names review those transitions |
|
|
71
|
+
| HZ059 | data reached plain JavaScript: a plain helper, a global (`Boolean`, `Object.keys`, `String`…), `typeof`, a spread or `in` | make the helper a `part()`; for a global use an operator or a `fn()` |
|
|
72
|
+
| HZ060 | a page route starts with a locale segment (`/de/…` under `site.locales`) | rename the route (patch); the locale prefix is added for you |
|
|
73
|
+
| HZ061 (warning) | a form-fed event payload declares limits | move them to the mutation input |
|
|
74
|
+
| HZ062 (warning) | a GET endpoint declares `invalidates` | `method: 'POST'`, or keep it on purpose (e-mail links) |
|
|
75
|
+
| HZ063 (warning) | as HZ055, in a form holding `ui.html`, a client component or another view | as HZ055 |
|
|
76
|
+
| HZ064 | two contracts with identical IR | remove one (patch) |
|
|
77
|
+
| HZ070 | a component's render references a declaration (event, query, route, message…) | pass a `Send` through `on`, an `Href` prop, text as a prop or slot |
|
|
78
|
+
| HZ071 | a variant from data | make it a prop, styled through an attribute (`aria-pressed:`, `data-[x=y]:`) |
|
|
79
|
+
| HZ072 | a caller's `class` sets a property the component owns | declare a variant; a one-off ends with `!` (snippet) |
|
|
80
|
+
| HZ073 | `!` inside a component / a leading `!x` | remove it (patch in the tv config, snippet in the render) / write `x!` (patch) |
|
|
81
|
+
| HZ074 | `!` inside a component / a leading `!x` | remove it (patch in the tv config, snippet in the render) / write `x!` (patch) |
|
|
82
|
+
| HZ075 (warning) | a caller's inherited class (colour, font) is hidden by an inner element | a variant, or style the inner element |
|
|
83
|
+
| HZ076 (warning) | a component owns a margin | remove it (patch); outer spacing is the caller's |
|
|
84
|
+
| HZ077 (warning) | `!` on a property the component does not own | remove the `!` (patch) |
|
|
85
|
+
| HZ078 | the kit's `tv.ts` config differs from the design system | `hozu add kit <id> --sync` |
|
|
86
|
+
| HZ079 | two classes of one element set the same property | the patch: a complementary toggle, or remove the one that never wins |
|
|
87
|
+
| HZ080 (warning) | a `part()` view inlined by two features | the snippet: the same `ui.component` in a kit |
|
|
88
|
+
| HZ081 | an effect's `runs` and `fetch.ts` disagree: no export, an extra one, a `'server'` effect in it, `'either'` with user data, a Node-only import | export the effect in `fetch.ts`, or `runs: 'server'` with a resolver (`hozu docs fetch`) |
|
|
89
|
+
| HZ082 | browser data where only the server can go (a page `head`, `entries`), or a browser mutation invalidating a server-cached tag | the patch: `runs: 'server'`; or `freshness: 'request'` on the cached query |
|
|
90
|
+
| HZ083 (warning) | fetch.ts calls an origin (an absolute URL in it) that the feature's `connect` does not list: the browser's CSP blocks it | add the origin to `feature({ connect })` (the fix lists the whole line); `{ env: 'NAME' }` for a URL from public env |
|
|
91
|
+
| HZ084 (warning) | a public env variable named like a secret (`SECRET`, `TOKEN`, `PASSWORD`, `PRIVATE`, `…_KEY`): public values reach the browser | move it to `env.server`; only a value made to be published (a publishable key) stays public, named `PUBLIC_…` |
|
|
92
|
+
| HZ085 | `env.internal` maps a name that is not a public variable, or to one that is not a server variable; or `site.url: { env }` names an undeclared variable | declare both: the public URL in `env.public`, the internal one in `env.server` |
|
|
93
|
+
| HZ086 (warning) | an env file listed in `env.files` exists and git does not ignore it | add it to `.gitignore`; commit `.env.example` (`npx hozu env --example`) instead |
|
|
94
|
+
| HZ087 (warning) | an entry of `project({ accept })` matches no warning, names an error, or has no reason | remove the entry when the warning is gone; fix an error instead of accepting it; give every entry a reason |
|
|
95
|
+
| HZ088 | a server-run `scope: 'user'` query or a server-run mutation without `access`, or an access rule that reads a field the output or session does not have | say who may run it: `access: 'signedIn'`, `{ owner: { row, session } }`, `{ allow: ({ session }) => … }`, or `'anyone'` |
|
|
96
|
+
| HZ089 (warning) | `access` on a public query or a browser-run effect, where the server cannot enforce it | remove it: a public query never sees the session, and a browser-run effect is guarded by the API it calls |
|
|
97
|
+
| HZ090 (warning) | `access: 'anyone'` on a `scope: 'user'` query: every visitor, signed in or not, may read it | say who may read it (`'signedIn'`, `{ owner: { row, session } }`), or accept the warning with a reason |
|
|
98
|
+
| HZ091 | a query with `owner` access returned rows the visitor does not own (reported at run time) | read only the visitor's rows in the resolver (filter by the session); production drops the extra rows and logs this |
|
|
99
|
+
| HZ092 | a preview in `project({ previews })` no longer fits the app: data off its query output schema, an error the query does not declare, a route without a page, or a component use that does not build | update the preview to the current schema, error, page or component (previews are for people: they never ship) |
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Endpoints (webhooks, JSON APIs, auth callbacks, downloads)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
export const exportNotes = endpoint({ method: 'GET', path: '/api/export', // exported from model.ts
|
|
5
|
+
input: z.object({ format: z.enum(['json', 'csv']).default('json') }), output: z.object({ notes: z.array(Note) }),
|
|
6
|
+
errors: { Unauthorized: z.object({ message: z.string() }) }, failed: { Unauthorized: 401 } })
|
|
7
|
+
implement(exportNotes, (input, { session, fail }) => // in the app's resolvers
|
|
8
|
+
session ? { notes: notesOf(session.user) } : fail('Unauthorized', { message: 'Sign in' }))
|
|
9
|
+
```
|
|
10
|
+
- GET input comes from the query string (coerced by the schema), POST input from a JSON or form body; invalid input
|
|
11
|
+
answers 400. `fail(name, payload)` answers `failed[name]`; every declared error is mapped (HZ046).
|
|
12
|
+
- `output`: a schema (sent as JSON), `'redirect'` (return `redirect(ui.link(route, params))` from the context, 303) or
|
|
13
|
+
`'response'` (a web `Response`, for files). Never HTML: a page is a `ui.page` (HZ053).
|
|
14
|
+
- Links: `ui.link(getEndpoint, input)` is the URL of a GET endpoint; `ui.form({ method: 'post', action:
|
|
15
|
+
ui.link(postEndpoint) }, [...])` posts a native form, whose fields the input declares (HZ046).
|
|
16
|
+
- Paths are static and outside pages, redirects, `/_hozu/` and the derived `/sitemap.xml`, `/robots.txt` and
|
|
17
|
+
`/manifest.webmanifest` (HZ046).
|
|
18
|
+
|
|
19
|
+
<!-- more -->
|
|
20
|
+
|
|
21
|
+
- Error bodies: invalid input is `{ error: 'Invalid', message, fields }`; a `fail` is `{ error, message, ...every field its error schema declares }`
|
|
22
|
+
(statuses 400 401 403 404 409 410 422 429).
|
|
23
|
+
- `'redirect'` keeps the basePath. `'response'` is for files and protocol bodies that are neither JSON nor HTML. A
|
|
24
|
+
`text/html` response is a 500 with HZ053: a page is a `ui.page` with `head.failed`.
|
|
25
|
+
- `input: 'raw'` (POST only) skips parsing and gives the resolver `bytes` (a `Uint8Array`), for signed webhooks.
|
|
26
|
+
- Headers and the raw request: the resolver's context has `request` (a web `Request`). A bearer token:
|
|
27
|
+
`implement(postItem, (input, { request, fail }) => request.headers.get('authorization') === `Bearer ${token}` ? … : fail('Unauthorized', {}))`
|
|
28
|
+
with `errors: { Unauthorized: z.object({}) }, failed: { Unauthorized: 401 }`. Try it from the DevTools API drawer
|
|
29
|
+
(Endpoints: path, body and your own headers) or `curl`. Queries and mutations read no headers: identity is the session.
|
|
30
|
+
- `setSession(value)` works on GET too (auth callbacks); OIDC form_post callbacks use `output: 'redirect'`.
|
|
31
|
+
- `invalidates: (input) => [itemsTag()]` refreshes like a mutation's when the endpoint succeeds. A GET endpoint with
|
|
32
|
+
`invalidates` is HZ062 (warning).
|
|
33
|
+
- A GET endpoint link takes flat scalar input (HZ035). Another feature links to an endpoint only when it is in
|
|
34
|
+
`exports` and imported (HZ006).
|
|
35
|
+
- HZ046 for a path comes with a patch. Responses carry `nosniff` and a referrer policy. Cross-site browser POSTs are
|
|
36
|
+
rejected; server-to-server calls (no `Origin`) are accepted.
|
|
37
|
+
- **What an endpoint cannot do yet:** methods are GET and POST; paths are static (a variable part goes in the
|
|
38
|
+
input: `POST /api/bookings/cancel` with `{ id }`); a success answers 200; an error answers its status with
|
|
39
|
+
`{ error, ...data }` (nested objects are fine, `error` is reserved); there are no custom response headers.
|