create-hyper-effect 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Potti1234
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,16 @@
1
- # Temporary Holding Version
1
+ # create-hyper-effect
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Creates a hyper-effect app from `template/`: one page with sign-in, live shared state, a validated form, regions, crease/ui components, Story and HTTP tests, and a Playwright browser check.
4
+
5
+ ```sh
6
+ npm create hyper-effect my-app # anywhere → ./my-app, then npm install
7
+ npm run create -- my-app --title "My App" # at the monorepo root → apps/my-app
8
+ npm install # links the new workspace
9
+ cd apps/my-app && npm start # http://localhost:4000
10
+ ```
11
+
12
+ Outside the monorepo, `npm create hyper-effect my-app` creates `./my-app`, depending on the `@hyper-effect/core` version published with it, with the hyper-effect agent skill in `.claude/skills`. Inside the monorepo the app goes to `apps/<name>` (a workspace on the local packages). `--core <version>` overrides the dependency (`file:<tarball>` for a packed build) and `--dir` the target.
13
+
14
+ The template is a working app, not a stub: `apps/starter` is generated from it, and its tests and browser check run with the rest. Change the template, regenerate `starter` (or apply the change to it), and run `npm run check` there.
15
+
16
+ Agents building on a generated app use the `hyper-effect` skill in `.claude/skills/hyper-effect`.
package/bin.mjs ADDED
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
3
+ import { dirname, join, relative, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ /* create-hyper-effect: a new hyper-effect app from the template.
7
+
8
+ usage: create-hyper-effect <name> [--title "My App"] [--dir <path>] [--core <version>]
9
+
10
+ Run with `npm create hyper-effect@latest <name>`: the app depends on the
11
+ @hyper-effect/core version this package was published with, and gets the
12
+ hyper-effect agent skill in .claude/skills. Run from inside the
13
+ hyper-effect monorepo, the app goes to apps/<name>, a workspace that uses
14
+ the monorepo's packages. --core overrides the dependency (a version, or
15
+ file:<tarball> for a packed build). */
16
+
17
+ const HERE = dirname(fileURLToPath(import.meta.url))
18
+ const TEMPLATE = join(HERE, 'template')
19
+ /** The agent skill, staged into the published package by tools/build/pack.mjs. */
20
+ const SKILL = join(HERE, 'skill')
21
+ const { version } = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8'))
22
+ /** Files renamed on the way: npm would treat them specially in the package. */
23
+ const RENAMES = { '_package.json': 'package.json', _gitignore: '.gitignore' }
24
+ const TEXT = /\.(ts|mjs|js|json|md|css|html|txt)$|^_?gitignore$/
25
+
26
+ const fail = message => {
27
+ console.error(`create-hyper-effect: ${message}`)
28
+ process.exit(1)
29
+ }
30
+
31
+ const args = process.argv.slice(2)
32
+ const option = name => {
33
+ const index = args.indexOf(`--${name}`)
34
+ if (index === -1) return undefined
35
+ const value = args[index + 1]
36
+ if (value === undefined || value.startsWith('--')) fail(`--${name} needs a value`)
37
+ args.splice(index, 2)
38
+ return value
39
+ }
40
+ const title = option('title')
41
+ const dir = option('dir')
42
+ const core = option('core')
43
+ const [name] = args
44
+ if (name === undefined || !/^[a-z][a-z0-9-]{0,50}$/.test(name)) {
45
+ fail('usage: create-hyper-effect <name> [--title "My App"] [--dir <path>] [--core <version>]\n <name>: lowercase letters, digits and dashes, starting with a letter')
46
+ }
47
+
48
+ /** The hyper-effect monorepo containing `from`, if any. */
49
+ const findMonorepo = from => {
50
+ for (let current = resolve(from); ; current = dirname(current)) {
51
+ const manifest = join(current, 'package.json')
52
+ if (existsSync(manifest) && JSON.parse(readFileSync(manifest, 'utf8')).name === 'hyper-effect-monorepo') return current
53
+ if (dirname(current) === current) return undefined
54
+ }
55
+ }
56
+
57
+ const monorepo = findMonorepo(process.cwd())
58
+ if (monorepo === undefined && core === undefined && version === '0.0.0') {
59
+ fail('this is an unpublished build: run it inside the hyper-effect monorepo (the app goes to apps/<name>), or pass --core <version>.')
60
+ }
61
+ const target = resolve(dir ?? (monorepo === undefined ? name : join(monorepo, 'apps', name)))
62
+ if (existsSync(target) && readdirSync(target).length > 0) fail(`${target} exists and is not empty`)
63
+
64
+ const values = {
65
+ name,
66
+ title: title ?? name.split('-').map(word => word[0].toUpperCase() + word.slice(1)).join(' '),
67
+ // Inside the monorepo, npm workspaces link the local package.
68
+ coreVersion: core ?? (monorepo === undefined ? version : '*'),
69
+ }
70
+ const fill = text => text.replace(/\{\{(name|title|coreVersion)\}\}/g, (_, key) => values[key])
71
+
72
+ const copy = (from, to) => {
73
+ mkdirSync(to, { recursive: true })
74
+ for (const entry of readdirSync(from)) {
75
+ const source = join(from, entry)
76
+ const destination = join(to, RENAMES[entry] ?? entry)
77
+ if (statSync(source).isDirectory()) copy(source, destination)
78
+ else if (TEXT.test(entry)) writeFileSync(destination, fill(readFileSync(source, 'utf8')))
79
+ else writeFileSync(destination, readFileSync(source))
80
+ }
81
+ }
82
+ copy(TEMPLATE, target)
83
+ if (monorepo === undefined && existsSync(SKILL)) {
84
+ cpSync(SKILL, join(target, '.claude', 'skills', 'hyper-effect'), { recursive: true })
85
+ }
86
+
87
+ const shown = relative(process.cwd(), target) || '.'
88
+ console.log(`Created ${values.title} in ${shown}.
89
+
90
+ Next:
91
+ ${monorepo === undefined ? ` cd ${shown}\n npm install` : ' npm install # at the monorepo root: links the new workspace'}
92
+ cd ${shown}
93
+ npm start # http://localhost:4000, sign in with any name
94
+ npm test
95
+ npm run check # the browser check, against the running server
96
+
97
+ Agents: the hyper-effect skill (.claude/skills/hyper-effect) explains how to build on it.`)
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "create-hyper-effect",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Creates a hyper-effect app: one page with sign-in, live shared state, a form, regions, tests and a browser check.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Potti1234/hyper-effect.git",
9
+ "directory": "packages/create-hyper-effect"
10
+ },
11
+ "homepage": "https://github.com/Potti1234/hyper-effect/tree/main/packages/create-hyper-effect#readme",
12
+ "bugs": "https://github.com/Potti1234/hyper-effect/issues",
13
+ "keywords": [
14
+ "foldkit",
15
+ "effect",
16
+ "datastar",
17
+ "create",
18
+ "template"
19
+ ],
20
+ "engines": {
21
+ "node": ">=22.19"
22
+ },
23
+ "type": "module",
24
+ "bin": {
25
+ "create-hyper-effect": "bin.mjs"
26
+ },
27
+ "files": [
28
+ "bin.mjs",
29
+ "template",
30
+ "skill"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,87 @@
1
+ ---
2
+ name: hyper-effect
3
+ description: Use whenever building or changing a hyper-effect app or page — server-side Foldkit programs on Effect, driven by Datastar. Triggers on imports from `@hyper-effect/core` or `@hyper-effect/island`, on files under apps/, examples/ or packages/ of the hyper-effect repository, on `npm run create`, and on requests like "add a page", "build an app with hyper-effect", "make this live/real-time", "add a form/list/upload to the app". Explains the architecture, the rules that keep pages correct and secure, the workflow, and where working references live.
4
+ ---
5
+
6
+ # hyper-effect
7
+
8
+ hyper-effect runs Foldkit programs on the server. Model, Message, `update`, `view`, Commands and Subscriptions are plain Foldkit; the update loop runs in a server session per open page, the view is rendered on the server, and Datastar in the browser applies the morphs and posts events. There is no client-side state to manage, no API layer, and no JSON contract between front and back: a page is one Foldkit program.
9
+
10
+ Load the **foldkit** skill as well: everything about Model, Message, update, view, Commands, Subscriptions, Foldkit UI and Story/Scene tests is Foldkit's and documented there. This skill covers what is different on the server.
11
+
12
+ **Where Foldkit's documentation is in this repository:** there is no vendored `repos/foldkit` here. Foldkit's API docs are the doc comments in `node_modules/foldkit/dist/**/*.d.ts` (for example `subscription/keyBindings.d.ts`, `command/index.d.ts`, `dom/dom.d.ts`), and Foldkit UI's in `node_modules/@foldkit/ui/dist`. crease/ui's sources are in the crease repository (`../crease/src/ui`, `src/lib`).
13
+
14
+ ## How to approach the work
15
+
16
+ - **Start from a working page.** `apps/starter` (generated by `npm run create`) is the smallest complete app; `examples/admin` is a business app (lists, filters, forms, uploads, roles, conflicts, Postgres); `examples/dispatch` is live collaboration (shared state, presence, islands, regions at scale). Pattern-match against them before inventing anything. File pointers by task are in [references/patterns.md](references/patterns.md).
17
+ - **One page = one program.** A page has its own `Model`, `Message`, `ClientMessage`, `flags`, `init`, `update`, `view` and `program`, in `src/app/<page>/page.ts`, mounted in `src/main.ts` with `Hyper.layer(program, { ...options, path })`. Shared state between users lives in services (stores) with a change feed, never in a page.
18
+ - **The architecture is not optional.** update and view stay pure; side effects are Commands; data arriving from outside is a Subscription. No module-level mutable state in pages, no reading the clock or random in update, no `window`/`document` anywhere on the server.
19
+ - **Make it work in a browser, not just in tests.** A page is done when `npm run typecheck`, `npm test` and the browser check (`npm run check` against `npm start`) pass, and you have looked at it.
20
+
21
+ ## A page in brief
22
+
23
+ ```ts
24
+ export const Model = S.Struct({ user: S.String, items: S.Array(Item), draft: S.String, error: S.NullOr(S.String) })
25
+ export const Message = defineMessageUnion({
26
+ ChangedDraft: { text: S.String }, // typed text: through onInput, never posted as a token
27
+ Submitted: {}, // a click/submit: a ClientMessage
28
+ GotItems: { items: S.Array(Item) }, // from a Subscription: a server fact
29
+ SucceededAdd: {}, // a Command result: a server fact
30
+ })
31
+ // What a browser may post directly. Everything else cannot be forged.
32
+ export const ClientMessage = S.Union([Message.Submitted])
33
+
34
+ export const program: Program<Model, Message, ClientMessage, Flags, Store> = {
35
+ name: 'items', // URL segment and snapshot key: unique per app
36
+ Model, ClientMessage,
37
+ flags: request => Effect.gen(function* () { /* authenticate, load */ }), // PageRedirect / PageNotFound
38
+ init, update, view, subscriptions,
39
+ snapshotDeps: model => [model.draft], // the session's own state; shared data comes back via Subscriptions
40
+ }
41
+ ```
42
+
43
+ The full skeleton with Commands, a Subscription on a store and regions is `apps/starter/src/app/tasks/page.ts`; the API is in [references/api.md](references/api.md).
44
+
45
+ ## Rules that keep pages correct
46
+
47
+ 1. **Bind only `ClientMessage`s in views.** `h.OnClick(Message.X())` renders a token the browser posts back; a Message outside `ClientMessage` fails the render. Command results and Subscription Messages must never be in `ClientMessage`.
48
+ 2. **Never trust what the browser posts.** A user can post any `ClientMessage` with any payload: re-check permissions and validate payloads in update (roles, ownership, ids that must exist), exactly as for an HTTP API.
49
+ 3. **Typed text goes through handlers.** `onInput`/`onChange` (crease `input`, `h.OnInput(f)`) bind function handlers. Such inputs are *client-owned*: the morph never overwrites what the user types; the server patches the value only when the Model diverges from what the browser sent (for example, clearing the field after save). Do not put typed text into a `ClientMessage`.
50
+ 4. **Regions: `region(h, { id, deps, render })`.** Wrap every part of a page that changes independently. `render` returns one element with `h.Id(id)`. `deps` must include every Model value the region's view reads (compared by value); outside production a region whose deps miss something fails to render with the field to add. Nested regions need their parent's deps to cover them. Lists: a region per row, inside a region for the list.
51
+ 5. **`shared: true` only for markup identical for every user.** A shared region renders once per deps for all sessions; it may bind Messages only, and must not read the user or any session state (development checks this across sessions).
52
+ 6. **Commands run on the server.** No browser globals. Browser effects (focus, scroll, URL replace, navigation) are forwarded via `clientCommands`. Commands whose effects must not happen twice (payments, emails) need idempotency: an event is applied once, but a Command in flight dies with its process. **Before building anything browser-only, read [references/browser.md](references/browser.md):** keyboard shortcuts, focus, drag and drop, menus and dialogs each have a known way.
53
+ 7. **The Model must be Schema-encodable.** It is snapshotted as JSON (restarts, failover): no functions, class instances, `File`s or Effects in it. Keep references (ids) and load the rest.
54
+ 8. **Flags authenticate and authorize.** Fail with `PageRedirect` (to /login) or `PageNotFound`; set `principal` in options so a session belongs to its user and ends when they sign out.
55
+ 9. **Shared data comes from stores with a change feed**, delivered by a Subscription. Build the Message once per change for all sessions with `memoizeLast`; throttle high-churn feeds with `sampleLatest`.
56
+
57
+ More pitfalls, each found the hard way: [references/gotchas.md](references/gotchas.md). Browser-only behaviour and how to get it: [references/browser.md](references/browser.md).
58
+
59
+ ## Workflow
60
+
61
+ 1. **New app:** `npm run create -- <name> [--title "Title"]` at the monorepo root, then `npm install`. It goes to `apps/<name>` with sign-in, a live page, a store, crease/ui components, Story and HTTP tests and a browser check.
62
+ 2. **New page:** copy the shape of `apps/starter/src/app/tasks/page.ts`; add a store service if it has shared data; mount it in `main.ts` with a unique `name` and a `path` (`/things/:id` gives `request.params.id` in flags).
63
+ 3. **UI:** crease/ui components are copied in (`src/ui`, behaviour in `src/lib`). More components: copy them from `examples/admin/src/ui` or `examples/dispatch/src/ui` (which have alert, badge, empty, field, native-select, pagination, table, textarea, toggle-group, …) together with their `src/lib` helper, or from the crease repository. Plain `h.*` elements with Tailwind classes are fine too.
64
+ 4. **Browser-only behaviour:** check [references/browser.md](references/browser.md) first. Keyboard shortcuts are `keyBindings(h, [...])`, focus is a forwarded Command, drag and drop between server-rendered targets binds natively. Only widgets that need the DOM itself (maps, canvas, rich editors, drag previews) are islands: Foldkit programs running in the browser inside the page.
65
+ 5. **Test:**
66
+ - update with Foldkit's `Story` (pure; resolve Commands by hand), the view with `Scene`;
67
+ - the page over HTTP with `@hyper-effect/core/testing` (`serve(program, options, store, servicesLayer)`, `openPage`, `postEvent`, `openStream`, `readUntil`, `token`). Pass the page's `path` in `options` for anything but `/`. The stream's first event is the whole page, so wait for text that only appears after your change;
68
+ - Story tests resolve every Command, forwarded ones included (a focus Command after a shortcut); test the logic with an inner update if a wrapper adds such Commands;
69
+ - extend `scripts/check.mjs` (Playwright) with every feature: only a browser shows morphs, focus and typing while the page updates.
70
+ 6. **Run:** `npm start` (development: markup validation and region checks on; 3–10× slower than production). Measure only with `NODE_ENV=production`.
71
+
72
+ ## Done checklist
73
+
74
+ - [ ] `npm run typecheck`, `npm test`, `npm run check` pass; no errors in the server log or the browser console.
75
+ - [ ] Every view-bound Message is in `ClientMessage`; update re-checks permissions for each.
76
+ - [ ] Every changing part is a region with complete deps (development renders would fail otherwise; open every state of the page once).
77
+ - [ ] No browser globals in Commands; forwarded browser effects are in `clientCommands`.
78
+ - [ ] Model is plain data; `snapshotDeps` names the session's own state.
79
+ - [ ] Looked at the page in a browser, in two windows if it has shared data.
80
+
81
+ ## Deeper references in the repository
82
+
83
+ - `README.md` (root): the architecture, correctness mechanisms, security, known limits.
84
+ - `docs/deployment.md`: proxies, compression, several processes, Postgres.
85
+ - `docs/performance.md`: what costs what, and the levers.
86
+ - `packages/core/README.md`: the exports and options.
87
+ - `packages/core/src/*.ts`: the source, with design notes at the top of each file.
@@ -0,0 +1,85 @@
1
+ # hyper-effect API
2
+
3
+ The source is the authority: `packages/core/src/*.ts` (doc comments on every export) and `packages/island/`. This page is the map.
4
+
5
+ ## `@hyper-effect/core`
6
+
7
+ | Export | What it is |
8
+ |---|---|
9
+ | `layer(program, options)` | Serves a program: the page at `options.path` (plain HTML, works without JavaScript), its SSE stream and its event and upload endpoints under `/_hyper/<program.name>/`. A Layer for `HttpRouter.serve`. |
10
+ | `Program<Model, Message, ClientMessage, Flags, R>` | The page: see below. |
11
+ | `PageRedirect({ location, status? })`, `PageNotFound()` | Fail `flags` with these: redirect (303 by default) or 404. |
12
+ | `PageRequest` | What `flags` gets: `url`, `params` (from `/things/:id`), `visitorId`, `cookies`, `headers`. |
13
+ | `region(h, { id, deps, render, shared? })` | A part of the page re-rendered and re-sent only when `deps` change (by value). |
14
+ | `shared(h, { key, deps, render })` | Markup rendered once per deps for every session and spliced in (Messages only); `region({ shared: true })` is usually what you want. |
15
+ | `keyBindings(h, bindings, { id?, sequenceTimeout? })` | Page-wide keyboard shortcuts, matched in the browser with Foldkit's `keyBindings` grammar and rules (`'C'`, `'Mod+K'`, `'Shift+?'`, `['G', 'M']`; `isEnabled`, `whileTyping`, `preventDefault`, `whenRepeated`). `message` is a ClientMessage, or a function of `{ targetId }` (the focused element's id) run in the session. Only matching keys post. Put it in the view, in a region whose deps cover what the table depends on. |
16
+ | `memoizeLast(f)` | `f` computed once per distinct input and shared: build a shared feed's Message once for all sessions. |
17
+ | `sampleLatest(interval)` | Stream operator: the first element at once, then at most one per interval (presence, counters). |
18
+ | `fileStore(directory)` | Session snapshots in files, each replaced whole. Development and one machine. |
19
+ | `sqlStore({ table?, ttl?, purgeEvery? })` | Session snapshots in SQL through Effect's `SqlClient` (provide `@effect/sql-pg`'s `PgClient.layer`). Several machines. |
20
+ | `metricsRoutes` | `GET /_hyper/metrics` and a CPU profile route. Opt-in, for load tests. |
21
+ | `nodeOfPath(path)` | The process named in a session path, for a router in front of several processes. |
22
+ | `foldkitUiCommands`, `ClientCommands`, `ClientAction` | Forwarding browser-only Commands. |
23
+
24
+ ### `Program`
25
+
26
+ | Field | Meaning |
27
+ |---|---|
28
+ | `name` | Endpoint segment and snapshot key. Unique per app. |
29
+ | `Model` | Schema of the Model. It is snapshotted as JSON: plain data only. |
30
+ | `ClientMessage` | Schema of the Messages a browser may post as tokens. Views can bind only these. |
31
+ | `flags(request)` | Effectful, request-aware Flags: authenticate, authorize, load. Runs on every page load, and when a session is recreated from its URL after a restart without a snapshot. |
32
+ | `init`, `update`, `view`, `subscriptions` | Foldkit's. `update` returns `{ model, commands? }`. |
33
+ | `onConnectionChange?(isConnected)` | A Message when the page's live connection opens or closes (presence, expensive feeds only while someone looks). |
34
+ | `clientCommands?` | `{ CommandName: args => ClientAction \| undefined }`: Commands not run on the server but performed by the browser after the next morph. Actions: `{ focus: selector }` (unless the user moved focus since they acted), `{ scrollIntoView: selector }`, `{ replaceUrl: url }`, `{ load: url }`, `{ click: selector }`. The Command's result Message is dropped. Foldkit UI's `FocusTab`, `FocusInput`, `FocusButton`, `FocusItems`, `ScrollIntoView` and `ClickItem` are forwarded already (`foldkitUiCommands`); an app's own entry of the same name wins. |
35
+ | `snapshotDeps?(model)` | The session's own state; snapshots are written when it changes. Leave out shared data that Subscriptions bring back. |
36
+
37
+ ### `Options` of `layer`
38
+
39
+ | Option | Default | Use |
40
+ |---|---|---|
41
+ | `path` | `/` | Path pattern, `/things/:id` |
42
+ | `stylesheets`, `scripts` | none | Linked from the page head (`scripts`: module scripts, such as an islands bundle) |
43
+ | `principal(request)` | none | Who a request acts for (the signed-in user); sessions are bound to it |
44
+ | `contentSecurityPolicy` | on | `{ directives: { 'connect-src': [...] } }` to add sources; `false` for none |
45
+ | `snapshotSecret` | none | Seals snapshots (AES-GCM); a list rotates |
46
+ | `snapshotTtl` | `1 day` | How long a snapshot can restore a session |
47
+ | `durableEvents` | `true` | Answer an event only once it is in the snapshot store (lossless failover) |
48
+ | `compression` | `false` | gzip per stream; ~30× fewer bytes, ~155 kB per open page |
49
+ | `idleTimeout` | `2 minutes` | How long a session without a connection is kept |
50
+ | `node` | none | Process name in session ids, for routing between processes |
51
+ | `trustProxy` | `0` | Number of proxies whose `X-Forwarded-*` count |
52
+ | `allowedOrigins` | `[]` | Extra origins allowed to post events |
53
+ | `limits` | see `server.ts` | Body size, event and page rates, sessions, uploads (`maxUploadBytes` 10 MB, `maxUploadFiles` 10) |
54
+
55
+ The app must provide a `KeyValueStore` (`fileStore`, `sqlStore`, or `KeyValueStore.layerMemory` in tests) and the services its programs need.
56
+
57
+ ## Event bindings (on `h`, Foldkit's builder, rendered by the server)
58
+
59
+ - `h.OnClick(Message.X())`, `h.OnSubmit(Message.X())` and other Message-valued events: posted as tokens; the Message must be a `ClientMessage`.
60
+ - `h.OnInput(value => Message.Changed({ value }))`, `h.OnChange(...)`: function handlers kept in the session; the input becomes client-owned. crease's `input({ onInput })` does this.
61
+ - Keyboard handlers (`OnKeyDown` etc.) receive the key and modifiers; `PreventDefault` variants are decided at render time for common keys. `OnPastePreventDefault` and `OnBeforeInputPreventDefault` are not supported.
62
+ - `h.OnFileChange(files => ...)`, `h.OnDropFiles(files => ...)`: the files are uploaded (multipart) and reach update as web `File`s (`foldkit/file`'s `File` schema in the Message).
63
+ - `OnMount` hooks are dropped (they need a browser): use an island.
64
+
65
+ ## `@hyper-effect/core/testing`
66
+
67
+ | Helper | Use |
68
+ |---|---|
69
+ | `serve(program, options?, store?, services?)` | A web handler for the program; `services` is a Layer providing what the program needs. Pass `{ path: '/things/:id' }` in `options` for a page not at `/`. `dispose()` when done. |
70
+ | `openPage(handler, path?, cookie?)` | A page GET; returns `html`, `sessionId`, `program`, and `cookie` (yours plus the visitor cookie). |
71
+ | `postEvent(handler, page, payload)` | Posts an event: `{ m: token(message), q }` for a ClientMessage, `{ h: handlerId, v: value, q }` for a handler. `q` counts up from 1 per page. |
72
+ | `openStream(handler, page)`, `readUntil(response, text)` | The SSE stream as the browser opens it, read until some text appears. Its first event is the whole page: wait for text only your change produces. |
73
+ | `token(value)` | The token for a Message (`{ _tag: 'Submitted' }`). |
74
+ | `makeStore()`, `waitForSnapshot(store, page)` | An in-memory snapshot store; wait for a session's snapshot. |
75
+
76
+ Handler ids are in the page: `data-hyper-input="<id>"` on client-owned inputs.
77
+
78
+ ## `@hyper-effect/island`
79
+
80
+ - `@hyper-effect/island/contract`: `define({ tag, Props, Event })`, shared by server and browser.
81
+ - `@hyper-effect/island`: `Island.view(spec, { id, props, onEvent, class? }, h)` in a server view; put it in a region.
82
+ - `@hyper-effect/island/client`: `register(spec, make)` in the browser bundle.
83
+ - `@hyper-effect/island/vite`: `islandsBuild({ entry, outDir })`; load the bundle with `options.scripts`.
84
+
85
+ Reference: `examples/dispatch/src/app/dispatch/islands/` and `src/client/islands.ts`.
@@ -0,0 +1,21 @@
1
+ # Browser-only behaviour, and how to get it
2
+
3
+ A page's update and view run on the server; the browser applies morphs and posts events. Most of what an app needs works that way unchanged. This table lists what does not, or needs a specific tool, with the way that works. Check it before reaching for an island.
4
+
5
+ | Behaviour | How | Reference |
6
+ |---|---|---|
7
+ | **Keyboard shortcuts for the whole page** (`c`, `/`, Mod+K, `G` then `M`) | `keyBindings(h, [...])` from `@hyper-effect/core`: Foldkit's key grammar, matched in the browser, only a matching key posts. `isEnabled` from the Model; `whileTyping: 'Allow'` for keys that must work in fields (Escape, Mod+K, Mod+Enter). `message` may be a function of `{ targetId }` (the focused element's id) when the field matters (Mod+Enter submits the form you are in). | `apps/starter` (`N`), `apps/linear-clone/src/app/shortcuts/contract.ts` |
8
+ | **Keys with modifiers on one element** (Ctrl+Enter in a textarea) | Not with `OnKeyDownPreventDefault` (probed without modifiers). Use a key binding with `whileTyping: 'Allow'` and a function `message` that checks `targetId`. | clone, `submit` shortcut |
9
+ | **Move focus** (open a panel and focus its input, focus a field after a shortcut) | A Command forwarded with `clientCommands: { FocusX: () => ({ focus: '#id' }) }`. It runs after the next morph, unless the user has moved focus since they acted (then it would steal it). Focus after a click or a key works. | template `FocusNewTask`, clone `FocusElement` |
10
+ | **Scroll an element into view** | `clientCommands: { ScrollX: … => ({ scrollIntoView: '#id' }) }` | clone `ScrollIntoView` |
11
+ | **Address bar** (filters, page number) | `{ replaceUrl }` forwarded; Flags read the URL back | admin `ReplaceUrl` |
12
+ | **Go to another page** | `{ load: url }` forwarded, or a plain link. Every page is a new session; there is no push-history yet (see `docs/design-latency.md`). | admin `LoadUrl` |
13
+ | **Drag and drop between server-rendered targets** (a board) | Works without an island: `OnDragStart`, `OnDragEnter`, `OnDrop` with Messages, and `AllowDrop` on targets. Drag previews, sorting animations and touch dragging need an island. | clone board |
14
+ | **Popover/menu positioning** | CSS anchor positioning (`anchor-name` / `position-anchor`), as crease's menus do. No measuring needed. | crease dropdown-menu |
15
+ | **Foldkit UI Submodels** (tabs, menus, combobox, listbox, select, command) | Run on the server unchanged. Their focus, scroll and click Commands are forwarded automatically (`foldkitUiCommands`), so arrow keys and Enter work. | `docs/compatibility.md` |
16
+ | **Menus with hover highlight** | crease `dropdownMenu({ activateOnHover: false })`: hover in CSS, no request per item. | clone ⋯ menu |
17
+ | **Modal dialogs** (crease dialog, sheet, drawer, alert-dialog, command in a dialog) | Do not open on a server yet (`ShowDialog` needs a browser and reports back). Render an overlay from the Model instead: a fixed panel plus backdrop, shown when the Model says so. | clone create-issue dialog |
18
+ | **`OnMount` hooks, measuring, canvas, maps, rich editors** | An island: a Foldkit program in the browser, with Props in and Events out. | `examples/dispatch` map |
19
+ | **Clipboard copy** | `h.OnCopyText(text)` is bound | — |
20
+ | **File uploads** | `h.OnFileChange` / `h.OnDropFiles`: uploaded, arrive as `File`s | admin images |
21
+ | **Typing, IME, autofill** | Client-owned inputs (`onInput`): handled, including change-only fills | `check:inputs` |
@@ -0,0 +1,46 @@
1
+ # Pitfalls
2
+
3
+ Each of these broke something during hyper-effect's development or experiments. Symptom first, then the fix.
4
+
5
+ ## Views and regions
6
+
7
+ - **"region … renders differently with the same deps"** (development). The region's view reads something its `deps` miss. Add every Model value it reads, including values used only for classes or labels. The time (`Date.now()`) and randomness are never allowed in a view: put the time in the Model (a tick Subscription) and add it to deps.
8
+ - **"region … keeps its deps while its nested region … changes".** A parent region's deps must cover everything its nested regions read, because an unchanged parent keeps its children without rendering them. Usually: put the list itself in the parent's deps.
9
+ - **"region … must render one element with h.Id(…)".** `render` returns exactly one element, and its id equals the region id.
10
+ - **"placed twice in one render".** Region ids are unique per page. Rows: `task-${task.id}`.
11
+ - **"a view bound a Message that is not a ClientMessage".** Add it to `ClientMessage` if a browser may really send it; otherwise the view must not bind it. Never add Command results or Subscription Messages: anyone could post them.
12
+ - **Shared region "renders differently … for another session".** A `shared: true` region read the user or other session state. Drop `shared`, or take that data out of the region.
13
+ - **Tailwind classes missing.** `src/styles.css` scans `src/` for class names: classes built at runtime (`` `text-${color}` ``) are not found. Write full class names, or map values to complete strings.
14
+ - **A Message returning the same Model** (`{ model }`) renders nothing. That is intended; mutate nothing in place, or the change goes unseen.
15
+
16
+ ## Events and inputs
17
+
18
+ - **Typed text lost, or overwritten while typing.** Bind inputs with `onInput`/`onChange` handlers (client-owned), never by posting their value in a ClientMessage, and never render a typed field's value from anything but the Model field the handler sets.
19
+ - **The field does not clear after saving.** Set the Model's draft to `''`; the server patches a client-owned input once the Model differs from what the browser sent. A view-only reset does nothing.
20
+ - **Double clicks post twice.** Requests are never cancelled (a cancelled click would be lost). Guard in update: ignore a submit while `isSaving`, and make actions idempotent where they matter.
21
+ - **Not supported:** `OnPastePreventDefault`, `OnBeforeInputPreventDefault`, `OnMount`. Use an island for anything that needs the DOM.
22
+ - **A key with modifiers on an element does nothing** (`OnKeyDownPreventDefault` returning Some only for Ctrl+Enter). Those handlers are probed at render time without modifiers. Use `keyBindings` with `whileTyping: 'Allow'`.
23
+ - **Shortcuts on `<body>` never fire.** Element key handlers only see keys while the element (or a child) has focus, and the view renders inside `<body>`. Use `keyBindings`.
24
+ - **Menus feel slow over a network.** Every hover or arrow key in a server-side Submodel is a round trip. Use `activateOnHover: false` on crease menus; `docs/design-latency.md` has what comes next.
25
+
26
+ ## Commands
27
+
28
+ - **"Command … needs a browser … skipped on the server".** It touched `window`, `document` or similar. Forward it with `clientCommands` (focus, scrollIntoView, replaceUrl, load) if its result Message is only an acknowledgement, or move the behaviour into an island.
29
+ - **A forwarded Command's result Message never arrives.** By design: forwarded Commands are performed by the browser after the morph, and their result is dropped. Do not forward Commands whose result drives the page (that is why crease's modal dialogs do not open on a server yet).
30
+ - **A forwarded focus does not happen.** It is skipped when the user has moved focus elsewhere since they acted, so it never steals focus. If it never happens, check that the selector exists after the morph and that the Command is in `clientCommands`.
31
+ - **A Command fails and nothing happens.** A failing Command is logged and produces no Message. Catch expected errors inside the Command and turn them into Messages (`FailedSave`, `FailedChange`).
32
+ - **Effects running twice.** Events are applied exactly once, even across retries and process failures; Commands are not re-run on failover, but one in flight when a process dies is lost. Make external effects idempotent (an idempotency key), or record the intent in the Model.
33
+
34
+ ## State and sessions
35
+
36
+ - **The Model must be plain data,** encodable with `Schema.toCodecJson(Model)`: it is snapshotted for restarts and failover. No `File`s (store them, keep an id), functions, class instances or Effects.
37
+ - **flags run more than once.** On every page load, and when a session is recreated from its URL after a restart without a snapshot (with the stream request's cookies). Keep them free of side effects that must happen once.
38
+ - **Shared data in snapshots.** Leave it out of `snapshotDeps`; the Subscription delivers it again after a restore. Otherwise every shared change rewrites every session's snapshot.
39
+ - **Two layers with the same `program.name`** share endpoints and snapshots. Every page needs its own name, also when one program serves several paths (export a `makeProgram(name)`, as the clone's `issues/page.ts` does).
40
+ - **Sessions end.** A tab that is closed (or loses its connection) for longer than `idleTimeout` (2 minutes) loses its session; reopening loads the page anew from flags. Keep durable state in stores, not in a page's Model.
41
+
42
+ ## Running and measuring
43
+
44
+ - **Development is 3–10× slower.** Markup validation and the region checks run on every render outside production. Measure with `NODE_ENV=production`.
45
+ - **Inline scripts or styles do not run.** The Content-Security-Policy allows only nonce'd hyper-effect scripts and your `scripts`/`stylesheets`. Add other origins with `contentSecurityPolicy.directives`.
46
+ - **Behind a proxy, live updates stall or posts get 403.** See `docs/deployment.md`: pass the `Host` header (or set `trustProxy` and `X-Forwarded-Host`), and never answer a dead upstream with an error status for session requests.
@@ -0,0 +1,69 @@
1
+ # Patterns, with working references
2
+
3
+ Every pattern here runs in the repository and is covered by a browser check. Read the referenced file before writing your own; copy its shape.
4
+
5
+ ## Pages and data
6
+
7
+ | Need | Reference | Notes |
8
+ |---|---|---|
9
+ | The smallest complete page | `apps/starter/src/app/tasks/page.ts` | Model, Messages, ClientMessage, Commands, Subscription, regions, snapshotDeps |
10
+ | Shared state, live for everyone | `apps/starter/src/app/tasks/store.ts`, `examples/dispatch/src/app/dispatch/store.ts` | A service with a `SubscriptionRef` and `changes` (current value first, then each new one). Pages subscribe; `memoizeLast` builds the Message once for all sessions |
11
+ | The same on Postgres, several processes | `examples/admin/src/app/catalog/postgres.ts` | Writes in a transaction with `pg_notify`; one `LISTEN` per process feeding a version the pages watch |
12
+ | A list with search, filters, sorting, pagination | `examples/admin/src/app/products/list.ts`, `catalog/domain.ts` | The query lives in the Model; `LoadPage` Command; ignore answers to older queries (`sameQuery`); a change feed only signals, each page re-queries |
13
+ | In-page state in the address bar | same, `ReplaceUrl` + `clientCommands` | Flags read the URL back (`queryFromUrl`); the Command is forwarded as `{ replaceUrl }` |
14
+ | A detail page from the URL | `examples/admin/src/app/products/edit.ts` (`/products/:id`), `examples/dispatch/src/app/dispatch/incident.ts` | `request.params.id` in flags; `PageNotFound` for unknown ids |
15
+ | Navigate after an action | `examples/admin/src/app/products/edit.ts`, `LoadUrl` | Forwarded as `{ load: url }` |
16
+
17
+ ## Forms
18
+
19
+ | Need | Reference | Notes |
20
+ |---|---|---|
21
+ | Typed fields | starter `newTaskForm`, admin `edit.ts` (`ChangedField`) | `input({ onInput })`: client-owned; the draft lives in the Model as strings |
22
+ | Validation with Schema | starter `validateTitle`, admin `domain.ts` `validateDraft` / `fieldErrors` | Validate in update on submit; errors keyed by field name, shown next to the field |
23
+ | Clear the form after saving | starter `SucceededAdd` | Set the draft to `''`: the server patches the client-owned input because the Model now differs from what the browser sent |
24
+ | Server-side errors (taken name, deleted row) | admin `SaveProduct` | The Command catches the error and returns a Message (`FailedSave`); never let a Command fail silently |
25
+ | Edit conflicts | admin `edit.ts`, `StaleRevision` | Save names the revision it started from; the store refuses stale writes; the user can take the other version |
26
+ | Submit right after typing | built in | Unsent typed values travel with every event and are applied first |
27
+
28
+ ## Users
29
+
30
+ | Need | Reference | Notes |
31
+ |---|---|---|
32
+ | Sign-in | `apps/starter/src/app/auth.ts` | Signed cookie; `requireUser` in flags, `principal` in options |
33
+ | Roles | `examples/admin/src/app/auth.ts`, `edit.ts` `editing(...)` | Hide controls in the view and refuse in update: the browser can post any ClientMessage |
34
+ | Presence (who is here) | `examples/dispatch/src/app/dispatch/board.ts`, `presence` subscription | `onConnectionChange` puts `isConnected` in the Model; the Subscription depends on it and joins only while connected; `sampleLatest` |
35
+
36
+ ## Rendering
37
+
38
+ | Need | Reference | Notes |
39
+ |---|---|---|
40
+ | Lists that change one row at a time | starter `taskList`, admin `results`, dispatch board rows | A region for the list, a region per row with `deps: [row]` |
41
+ | Rows every user sees alike | dispatch board, `region({ shared: true })` | Rendered once per change for all sessions; Messages only, no user data |
42
+ | crease/ui components | `src/ui/*` + `src/lib/*` in each app | Copy more from `examples/*/src/ui`; they take `(props, h)` |
43
+ | Stateful widgets (tabs, toggle group, menus) | dispatch board `ToggleGroup` (`src/ui/toggle-group.ts`) | Foldkit UI Submodels run on the server unchanged; their focus Commands are forwarded (`foldkitUiCommands`) |
44
+ | Browser-only widgets (maps, canvas, rich editors, drag previews) | `examples/dispatch/src/app/dispatch/islands/`, `src/client/islands.ts`, `vite.islands.config.ts` | An island: a Foldkit program in the browser; Props in, Events out as Messages; put `Island.view` in a region; the bundle via `options.scripts` and a CSP `connect-src` for its sources. Check `browser.md` first: shortcuts, focus and drag and drop need no island |
45
+
46
+ ## Keyboard, focus, menus
47
+
48
+ | Need | Reference | Notes |
49
+ |---|---|---|
50
+ | Page-wide shortcuts | `apps/starter` (`N`), `apps/linear-clone/src/app/shortcuts/contract.ts` | `keyBindings(h, [...])`; the clone's table has single keys, sequences (`G` `M`), Mod+K and Mod+Enter while typing, and turns list keys off while an overlay is open |
51
+ | Focus after an action | template `FocusNewTask`, clone `FocusElement` (and its `update` wrapper) | A Command forwarded as `{ focus }` |
52
+ | A filterable picker (Linear's status/assignee menus) | `apps/linear-clone/src/app/view/picker.ts` | State in the page Model, items filtered in update, anchored with CSS, keys on its input |
53
+ | A command palette | the same picker in `command` mode; crease `command` rendered inline also works now (Enter is forwarded) | Not inside crease's modal dialog yet |
54
+ | Menus | crease `dropdownMenu` as a Submodel, with `activateOnHover: false` | clone issue page, the menu next to presence |
55
+ | Drag and drop on a board | `apps/linear-clone/src/app/issues/page.ts` (board) | Native drag events with Messages; no island |
56
+ | Inline editing saved on blur | clone issue page description | crease `textarea` with `attributes: [h.OnBlur(...)]` |
57
+
58
+ ## Files
59
+
60
+ | Need | Reference | Notes |
61
+ |---|---|---|
62
+ | Upload | admin `edit.ts` (`ChoseImage`, `StoreImage`), `catalog/images.ts` | `h.OnFileChange` / `h.OnDropFiles`; the Message carries `File`s; a Command stores them; the Model keeps a reference only. Check the bytes, not the claimed type; serve with `nosniff` |
63
+
64
+ ## Running it
65
+
66
+ | Need | Reference |
67
+ |---|---|
68
+ | Behind nginx, HTTP/2, compression, several processes, Postgres | `docs/deployment.md` |
69
+ | Load and soak tests | `docs/performance.md`, `tools/perf/` |
@@ -0,0 +1,10 @@
1
+ # Working on {{title}}
2
+
3
+ This is a hyper-effect app. Before changing it, load the `hyper-effect` skill (and the `foldkit` skill for Foldkit itself); they explain the architecture, the rules and where to find working references.
4
+
5
+ - Pages are Foldkit programs whose update loop runs on the server. Keep update and view pure; side effects are Commands.
6
+ - Only Messages listed in a page's `ClientMessage` may be bound in its view; typed text goes through `onInput`/`onChange` handlers. Re-check permissions in update.
7
+ - Wrap independently changing parts of a page in `region(h, { id, deps, render })` and list every Model field the region reads in `deps`.
8
+ - Commands run on the server: no `window`, `document` or `localStorage`.
9
+ - Keyboard shortcuts, focus, drag and drop, menus and dialogs: read the skill's `references/browser.md` before building them (shortcuts are `keyBindings(h, [...])`; focus is a forwarded Command).
10
+ - Done means: `npm run typecheck`, `npm test` and `npm run check` (against `npm start`) pass.
@@ -0,0 +1,39 @@
1
+ # {{title}}
2
+
3
+ A hyper-effect app: Foldkit programs whose loop runs on the server, on Effect, with Datastar in the browser.
4
+
5
+ ```sh
6
+ npm start # builds the CSS, serves http://localhost:4000
7
+ npm run dev # the same, restarting on changes
8
+ npm test # update (Foldkit Story) and the page over HTTP
9
+ npm run typecheck
10
+ npm run check # two people in Chromium against the running server
11
+ ```
12
+
13
+ Sign in with any name. Open a second window with another name to watch changes arrive live. Press `N` to jump to the new-task field.
14
+
15
+ | File | What it is |
16
+ |---|---|
17
+ | `src/main.ts` | The server: the page's layer, sign-in routes, services, snapshot store |
18
+ | `src/app/tasks/page.ts` | The page: Model, Message, update, view, Commands, Subscription |
19
+ | `src/app/tasks/store.ts` | Shared state with a change feed (in memory; a database in a real app) |
20
+ | `src/app/auth.ts` | Demo sign-in with a signed cookie |
21
+ | `src/ui`, `src/lib` | crease/ui components (button, input, card); copy more from crease |
22
+ | `test/tasks.test.ts` | Story tests of update, HTTP tests of the page |
23
+ | `scripts/check.mjs` | The browser check |
24
+
25
+ ## Adding a page
26
+
27
+ 1. Create `src/app/<page>/page.ts` with `Model`, `Message`, `ClientMessage`, `update`, `view` and `program`, like `src/app/tasks/page.ts`.
28
+ 2. Mount it in `src/main.ts`: `Hyper.layer(Page.program, { ...page, path: '/things/:id' })`.
29
+ 3. Add Story tests for update and a step to `scripts/check.mjs`.
30
+
31
+ ## Environment
32
+
33
+ | Variable | Default | Use |
34
+ |---|---|---|
35
+ | `PORT` | `4000` | Listen port |
36
+ | `APP_SECRET` | dev value (required in production) | Signs the sign-in cookie |
37
+ | `HYPER_SNAPSHOT_SECRET` | dev value (required in production) | Seals session snapshots in `.hyper-sessions/` |
38
+
39
+ Set both secrets outside development.
@@ -0,0 +1,3 @@
1
+ node_modules
2
+ public/styles.css
3
+ .hyper-sessions/
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "description": "{{title}}: a hyper-effect app.",
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "npm run css && tsx watch src/main.ts",
9
+ "start": "npm run css && node --import tsx src/main.ts",
10
+ "css": "tailwindcss -i src/styles.css -o public/styles.css",
11
+ "typecheck": "tsc --noEmit",
12
+ "test": "vitest run",
13
+ "check": "node scripts/check.mjs"
14
+ },
15
+ "dependencies": {
16
+ "@effect/platform-node": "4.1.2",
17
+ "@foldkit/ui": "0.170.0",
18
+ "@hyper-effect/core": "{{coreVersion}}",
19
+ "class-variance-authority": "^0.7.1",
20
+ "clsx": "^2.1.1",
21
+ "effect": "4.1.2",
22
+ "foldkit": "0.170.0",
23
+ "tailwind-merge": "^3.7.0"
24
+ },
25
+ "devDependencies": {
26
+ "@effect/vitest": "4.1.2",
27
+ "@tailwindcss/cli": "^4.3.1",
28
+ "@types/node": "^22.0.0",
29
+ "playwright": "1.62.1",
30
+ "tailwindcss": "^4.3.1",
31
+ "tsx": "^4.23.1",
32
+ "typescript": "^6.0.3",
33
+ "vitest": "^5.0.5"
34
+ }
35
+ }