@hyper-effect/core 0.0.0-stage → 0.1.0-alpha.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 +21 -0
- package/README.md +55 -3
- package/dist/binding.d.ts +84 -0
- package/dist/binding.js +465 -0
- package/dist/builder.d.ts +21 -0
- package/dist/builder.js +138 -0
- package/dist/client-commands.d.ts +61 -0
- package/dist/client-commands.js +70 -0
- package/dist/compression.d.ts +6 -0
- package/dist/compression.js +28 -0
- package/dist/datastar-bundle.d.ts +4 -0
- package/dist/datastar-bundle.js +8 -0
- package/dist/datastar.d.ts +11 -0
- package/dist/datastar.js +20 -0
- package/dist/deps.d.ts +5 -0
- package/dist/deps.js +43 -0
- package/dist/file-reader.d.ts +2 -0
- package/dist/file-reader.js +61 -0
- package/dist/file-store.d.ts +3 -0
- package/dist/file-store.js +35 -0
- package/dist/foldkit-internal.d.ts +4 -0
- package/dist/foldkit-internal.js +23 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +19 -0
- package/dist/intake.d.ts +41 -0
- package/dist/intake.js +138 -0
- package/dist/keys.d.ts +57 -0
- package/dist/keys.js +97 -0
- package/dist/local.d.ts +61 -0
- package/dist/local.js +25 -0
- package/dist/metrics.d.ts +54 -0
- package/dist/metrics.js +131 -0
- package/dist/program.d.ts +85 -0
- package/dist/program.js +29 -0
- package/dist/region.d.ts +65 -0
- package/dist/region.js +152 -0
- package/dist/security.d.ts +58 -0
- package/dist/security.js +149 -0
- package/dist/server.d.ts +128 -0
- package/dist/server.js +1164 -0
- package/dist/session.d.ts +139 -0
- package/dist/session.js +706 -0
- package/dist/shared.d.ts +35 -0
- package/dist/shared.js +112 -0
- package/dist/sql-store.d.ts +12 -0
- package/dist/sql-store.js +39 -0
- package/dist/streams.d.ts +18 -0
- package/dist/streams.js +25 -0
- package/dist/testing.d.ts +32 -0
- package/dist/testing.js +69 -0
- package/package.json +51 -4
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,55 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# @hyper-effect/core
|
|
2
|
+
|
|
3
|
+
Foldkit programs whose loop runs on the server, on Effect, rendered with Foldkit views and driven by Datastar in the browser.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import * as Hyper from '@hyper-effect/core'
|
|
7
|
+
|
|
8
|
+
HttpRouter.serve(
|
|
9
|
+
Layer.mergeAll(
|
|
10
|
+
Hyper.layer(Board.program, { path: '/', stylesheets: ['/styles.css'] }),
|
|
11
|
+
Hyper.layer(Incident.program, { path: '/incidents/:id' }),
|
|
12
|
+
),
|
|
13
|
+
)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
A `Program` is Foldkit's application shape plus what a server needs:
|
|
17
|
+
- a `name` for its endpoints;
|
|
18
|
+
- `Model` and `ClientMessage` Schemas (snapshots, unforgeable events);
|
|
19
|
+
- effectful, request-aware `flags`, which can fail with `PageNotFound` or `PageRedirect`.
|
|
20
|
+
|
|
21
|
+
## Exports
|
|
22
|
+
|
|
23
|
+
| Entry | Contents |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `@hyper-effect/core` | `layer`, `Options`, `Limits`, `Program`, `PageRequest`, `PageNotFound`, `PageRedirect`, `RequestContext`, `ContentSecurityPolicy`, `Secret`, `region`, `shared`, `keyBindings`, `sampleLatest`, `memoizeLast`, `nodeOfPath`, `fileStore`, `sqlStore`, `foldkitUiCommands`, `metricsRoutes` |
|
|
26
|
+
| `@hyper-effect/core/binding` | Event-to-Datastar rewriting, for packages that render their own elements (islands) |
|
|
27
|
+
| `@hyper-effect/core/builder` | The server `HtmlBuilder` and `binderOf` |
|
|
28
|
+
| `@hyper-effect/core/testing` | `serve` (with the program's services as a Layer), `openPage`, `postEvent`, `openStream`, `readUntil`, `makeStore`, `waitForSnapshot`, `token`, `renderModel` |
|
|
29
|
+
|
|
30
|
+
## Options worth knowing
|
|
31
|
+
|
|
32
|
+
| Option | Default | What it does |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `principal` | none | Who a request acts for; sessions are bound to it |
|
|
35
|
+
| `contentSecurityPolicy` | on | Nonce-based policy; add sources per directive, `reportOnly`, or `false` |
|
|
36
|
+
| `snapshotSecret` | none | Seals snapshots (AES-256-GCM); a list rotates secrets |
|
|
37
|
+
| `snapshotTtl` | `1 day` | Older snapshots no longer restore a session |
|
|
38
|
+
| `compression` | `false` | Brotli (or gzip) per event stream, one compressor per stream flushed after each patch |
|
|
39
|
+
| `durableEvents` | `true` | Answer an event only once it is in the snapshot store, so a failing process loses no answered event; `false` snapshots a quiet second later |
|
|
40
|
+
| `trustProxy` | `0` | Proxies whose `X-Forwarded-For` / `X-Forwarded-Host` entries count |
|
|
41
|
+
| `node` | none | This process's name when several serve one app: session ids become `<node>.<uuid>`, for routing (`nodeOfPath`) |
|
|
42
|
+
| `allowedOrigins` | `[]` | Extra origins allowed to post events |
|
|
43
|
+
| `limits` | see `server.ts` | Body size, event rate, page rate per client, upload bytes and files, sessions per visitor and per program |
|
|
44
|
+
|
|
45
|
+
`Program.snapshotDeps` (on the program, not an option) names the session's own state; outside events, snapshots are written only when it, or the intake position, changes.
|
|
46
|
+
|
|
47
|
+
Snapshots go to the `KeyValueStore` the app provides:
|
|
48
|
+
- `fileStore(directory)` keeps them in files, for development and for processes on one machine. Its writes replace a file whole (Effect's `KeyValueStore.layerFileSystem` writes in place, and a process killed mid-write leaves a snapshot that cannot be read).
|
|
49
|
+
- `sqlStore({ table, ttl, purgeEvery })` keeps them in a SQL database through Effect's `SqlClient` (the app provides the driver, such as `@effect/sql-pg`), for processes on several machines. Writes are one upsert; rows expire `ttl` after their last write, are never read after, and are purged periodically, which deletes the snapshots of sessions whose process failed.
|
|
50
|
+
|
|
51
|
+
Event streams carry `X-Accel-Buffering: no`, so nginx and proxies that honour it pass patches through at once; see [docs/deployment.md](../../docs/deployment.md).
|
|
52
|
+
|
|
53
|
+
Environment: `NODE_ENV=production` turns off markup validation, the region deps and id checks, and stack capture in views (`HYPER_VIEW_STACKS=1` keeps the latter).
|
|
54
|
+
|
|
55
|
+
Tests: `test/` (vitest), against the crease-free fixture in `test/fixtures/greeter.ts`.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Option } from 'effect';
|
|
2
|
+
import type { Attribute, ChildAttribute, HtmlBuilder, KeyboardModifiers } from 'foldkit/html';
|
|
3
|
+
export type Handler<Message> = Readonly<{
|
|
4
|
+
_tag: 'Value';
|
|
5
|
+
f: (value: string) => Message;
|
|
6
|
+
/** Set when the handler belongs to a client-owned input. */
|
|
7
|
+
inputId?: string;
|
|
8
|
+
}> | Readonly<{
|
|
9
|
+
_tag: 'Key';
|
|
10
|
+
f: (key: string, modifiers: KeyboardModifiers) => Option.Option<Message>;
|
|
11
|
+
}> | Readonly<{
|
|
12
|
+
_tag: 'Number';
|
|
13
|
+
f: (value: number) => Message;
|
|
14
|
+
}> | Readonly<{
|
|
15
|
+
_tag: 'Custom';
|
|
16
|
+
name: string;
|
|
17
|
+
f: (event: CustomEvent<unknown>) => Option.Option<Message>;
|
|
18
|
+
}>
|
|
19
|
+
/** Event data posted as an array (pointer and beforeinput events). */
|
|
20
|
+
| Readonly<{
|
|
21
|
+
_tag: 'Args';
|
|
22
|
+
f: (args: ReadonlyArray<unknown>) => Option.Option<Message>;
|
|
23
|
+
}>
|
|
24
|
+
/** A constant Message that is not JSON-encodable, so it cannot be a token. */
|
|
25
|
+
| Readonly<{
|
|
26
|
+
_tag: 'Message';
|
|
27
|
+
message: Message;
|
|
28
|
+
}>
|
|
29
|
+
/** Files chosen or dropped, uploaded to the session (`OnFileChange`,
|
|
30
|
+
* `OnDropFiles`). */
|
|
31
|
+
| Readonly<{
|
|
32
|
+
_tag: 'Files';
|
|
33
|
+
f: (files: ReadonlyArray<File>) => Message;
|
|
34
|
+
}>;
|
|
35
|
+
export interface Binder<Message> {
|
|
36
|
+
/** Path the browser posts events to. */
|
|
37
|
+
readonly endpoint: string;
|
|
38
|
+
/** Binds a Message-valued event: returns the payload fields to post
|
|
39
|
+
* (`m: '<token>'`, or `h: '<id>'` for a Message that is not
|
|
40
|
+
* JSON-encodable). Throws for Messages outside `ClientMessage`. */
|
|
41
|
+
readonly bindMessage: (message: Message) => string;
|
|
42
|
+
/** Registers a function-valued handler for this render and returns its id. */
|
|
43
|
+
readonly registerHandler: (handler: Handler<Message>) => string;
|
|
44
|
+
/** Registers a client-owned input and the value the Model holds for it. */
|
|
45
|
+
readonly registerInput: (inputId: string, value: string) => void;
|
|
46
|
+
}
|
|
47
|
+
/** Maps a child binder's Messages into the parent's, for `h.submodel`. */
|
|
48
|
+
export declare const mapBinder: <Child, Parent>(parent: Binder<Parent>, toParent: (message: Child) => Parent) => Binder<Child>;
|
|
49
|
+
/** Signal backing a client-owned input. Datastar camel-cases the attribute
|
|
50
|
+
* key, so `data-bind:_hv-name` binds the signal `_hvName`. The leading
|
|
51
|
+
* underscore keeps it out of requests. */
|
|
52
|
+
export declare const inputSignal: (inputId: string) => {
|
|
53
|
+
key: string;
|
|
54
|
+
name: string;
|
|
55
|
+
};
|
|
56
|
+
/** Signal holding the page's event sequence number. */
|
|
57
|
+
export declare const SEQUENCE_SIGNAL = "_hq";
|
|
58
|
+
/** Signal holding the session's event endpoint. Keeping the session id out
|
|
59
|
+
* of every binding makes markup that binds only Messages identical across
|
|
60
|
+
* sessions, so it can be rendered once and shared (see `shared.ts`). */
|
|
61
|
+
export declare const ENDPOINT_SIGNAL = "_he";
|
|
62
|
+
/** Page-script functions the bindings call (see `server.ts`). */
|
|
63
|
+
export declare const PENDING_INPUTS = "hyperPendingInputs";
|
|
64
|
+
export declare const FRESH_VALUE = "hyperFreshValue";
|
|
65
|
+
export declare const FOCUS = "hyperFocus";
|
|
66
|
+
/** Page script function that uploads files to the session. */
|
|
67
|
+
export declare const UPLOAD = "hyperUpload";
|
|
68
|
+
/** Path of a session's upload endpoint, below its event endpoint. */
|
|
69
|
+
export declare const UPLOAD_PATH = "/upload";
|
|
70
|
+
export declare const FOCUS_KEY = "hyperFocusKey";
|
|
71
|
+
export declare const COPY_TEXT = "hyperCopyText";
|
|
72
|
+
/** Page script function matching key bindings (`keys.ts`). */
|
|
73
|
+
export declare const KEYS = "hyperKeys";
|
|
74
|
+
/** Marks a client-owned input with its value handler id. */
|
|
75
|
+
export declare const INPUT_HANDLER_ATTRIBUTE = "data-hyper-input";
|
|
76
|
+
export declare class UnsupportedEventError extends Error {
|
|
77
|
+
readonly tag: string;
|
|
78
|
+
constructor(tag: string);
|
|
79
|
+
}
|
|
80
|
+
type AnyAttribute<Message> = Attribute<Message> | ChildAttribute;
|
|
81
|
+
/** Rewrites one element's attributes. */
|
|
82
|
+
export declare const rewriteAttributes: <Message>(attributes: ReadonlyArray<AnyAttribute<Message>>, binder: Binder<Message>, h: HtmlBuilder<Message>, rootBinder?: Binder<unknown>) => ReadonlyArray<AnyAttribute<Message>>;
|
|
83
|
+
export declare const rewriteAttribute: <Message>(attribute: AnyAttribute<Message>, binder: Binder<Message>, h: HtmlBuilder<Message>, inputId?: string, rootBinder?: Binder<unknown>) => AnyAttribute<Message>;
|
|
84
|
+
export {};
|
package/dist/binding.js
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { Option } from 'effect';
|
|
2
|
+
import { LOCAL, LOCAL_ATTRIBUTE, eventOfBinding } from "./local.js";
|
|
3
|
+
/** Maps a child binder's Messages into the parent's, for `h.submodel`. */
|
|
4
|
+
export const mapBinder = (parent, toParent) => ({
|
|
5
|
+
endpoint: parent.endpoint,
|
|
6
|
+
bindMessage: message => parent.bindMessage(toParent(message)),
|
|
7
|
+
registerHandler: handler => parent.registerHandler(mapHandler(handler, toParent)),
|
|
8
|
+
registerInput: parent.registerInput,
|
|
9
|
+
});
|
|
10
|
+
const mapHandler = (handler, toParent) => {
|
|
11
|
+
switch (handler._tag) {
|
|
12
|
+
case 'Value': {
|
|
13
|
+
const f = (value) => toParent(handler.f(value));
|
|
14
|
+
return handler.inputId === undefined
|
|
15
|
+
? { _tag: 'Value', f }
|
|
16
|
+
: { _tag: 'Value', f, inputId: handler.inputId };
|
|
17
|
+
}
|
|
18
|
+
case 'Number':
|
|
19
|
+
return { _tag: 'Number', f: (value) => toParent(handler.f(value)) };
|
|
20
|
+
case 'Key':
|
|
21
|
+
return {
|
|
22
|
+
_tag: 'Key',
|
|
23
|
+
f: (key, modifiers) => Option.map(handler.f(key, modifiers), toParent),
|
|
24
|
+
};
|
|
25
|
+
case 'Custom':
|
|
26
|
+
return {
|
|
27
|
+
_tag: 'Custom',
|
|
28
|
+
name: handler.name,
|
|
29
|
+
f: (event) => Option.map(handler.f(event), toParent),
|
|
30
|
+
};
|
|
31
|
+
case 'Args':
|
|
32
|
+
return {
|
|
33
|
+
_tag: 'Args',
|
|
34
|
+
f: (args) => Option.map(handler.f(args), toParent),
|
|
35
|
+
};
|
|
36
|
+
case 'Message':
|
|
37
|
+
return { _tag: 'Message', message: toParent(handler.message) };
|
|
38
|
+
case 'Files':
|
|
39
|
+
return { _tag: 'Files', f: (files) => toParent(handler.f(files)) };
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/** Signal backing a client-owned input. Datastar camel-cases the attribute
|
|
43
|
+
* key, so `data-bind:_hv-name` binds the signal `_hvName`. The leading
|
|
44
|
+
* underscore keeps it out of requests. */
|
|
45
|
+
export const inputSignal = (inputId) => {
|
|
46
|
+
const slug = inputId.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
47
|
+
return {
|
|
48
|
+
key: `_hv-${slug}`,
|
|
49
|
+
name: `_hv${slug.charAt(0).toUpperCase()}${slug.slice(1)}`,
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
/** Signal holding the page's event sequence number. */
|
|
53
|
+
export const SEQUENCE_SIGNAL = '_hq';
|
|
54
|
+
/** Signal holding the session's event endpoint. Keeping the session id out
|
|
55
|
+
* of every binding makes markup that binds only Messages identical across
|
|
56
|
+
* sessions, so it can be rendered once and shared (see `shared.ts`). */
|
|
57
|
+
export const ENDPOINT_SIGNAL = '_he';
|
|
58
|
+
/** Page-script functions the bindings call (see `server.ts`). */
|
|
59
|
+
export const PENDING_INPUTS = 'hyperPendingInputs';
|
|
60
|
+
export const FRESH_VALUE = 'hyperFreshValue';
|
|
61
|
+
export const FOCUS = 'hyperFocus';
|
|
62
|
+
/** Page script function that uploads files to the session. */
|
|
63
|
+
export const UPLOAD = 'hyperUpload';
|
|
64
|
+
/** Path of a session's upload endpoint, below its event endpoint. */
|
|
65
|
+
export const UPLOAD_PATH = '/upload';
|
|
66
|
+
export const FOCUS_KEY = 'hyperFocusKey';
|
|
67
|
+
export const COPY_TEXT = 'hyperCopyText';
|
|
68
|
+
/** Page script function matching key bindings (`keys.ts`). */
|
|
69
|
+
export const KEYS = 'hyperKeys';
|
|
70
|
+
/** Marks a client-owned input with its value handler id. */
|
|
71
|
+
export const INPUT_HANDLER_ATTRIBUTE = 'data-hyper-input';
|
|
72
|
+
export class UnsupportedEventError extends Error {
|
|
73
|
+
tag;
|
|
74
|
+
constructor(tag) {
|
|
75
|
+
super(`hyper-effect: the Foldkit event attribute "${tag}" has no server binding. ` +
|
|
76
|
+
'Render this part as a Foldkit island or use a supported event.');
|
|
77
|
+
this.tag = tag;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** A value embedded in an expression, decoded by the page script. */
|
|
81
|
+
const literal = (value) => `'${Buffer.from(JSON.stringify(value), 'utf8').toString('base64url')}'`;
|
|
82
|
+
const THROTTLE = '__throttle.100ms';
|
|
83
|
+
const LEAVES_ELEMENT = '!el.contains(evt.relatedTarget)';
|
|
84
|
+
const MESSAGE_EVENTS = {
|
|
85
|
+
OnClick: { event: 'click' },
|
|
86
|
+
OnDoubleClick: { event: 'dblclick' },
|
|
87
|
+
OnMouseDown: { event: 'mousedown' },
|
|
88
|
+
OnMouseUp: { event: 'mouseup' },
|
|
89
|
+
OnMouseEnter: { event: 'mouseenter' },
|
|
90
|
+
OnMouseLeave: { event: 'mouseleave' },
|
|
91
|
+
OnMouseOver: { event: 'mouseover' },
|
|
92
|
+
OnMouseOut: { event: 'mouseout' },
|
|
93
|
+
OnMouseMove: { event: 'mousemove', modifiers: THROTTLE },
|
|
94
|
+
OnFocus: { event: 'focus' },
|
|
95
|
+
OnBlur: { event: 'blur' },
|
|
96
|
+
OnFocusEnter: { event: 'focusin', guard: LEAVES_ELEMENT },
|
|
97
|
+
OnFocusLeave: { event: 'focusout', guard: LEAVES_ELEMENT },
|
|
98
|
+
OnSubmit: { event: 'submit', modifiers: '__prevent' },
|
|
99
|
+
OnReset: { event: 'reset' },
|
|
100
|
+
OnWheel: { event: 'wheel', modifiers: THROTTLE },
|
|
101
|
+
OnCopy: { event: 'copy' },
|
|
102
|
+
OnCut: { event: 'cut' },
|
|
103
|
+
OnPaste: { event: 'paste' },
|
|
104
|
+
OnCancel: { event: 'cancel' },
|
|
105
|
+
OnContextMenu: { event: 'contextmenu' },
|
|
106
|
+
OnDragStart: { event: 'dragstart' },
|
|
107
|
+
OnDrag: { event: 'drag', modifiers: THROTTLE },
|
|
108
|
+
OnDragEnd: { event: 'dragend' },
|
|
109
|
+
OnDragEnter: { event: 'dragenter' },
|
|
110
|
+
OnDragLeave: { event: 'dragleave' },
|
|
111
|
+
OnDragOver: { event: 'dragover', modifiers: THROTTLE },
|
|
112
|
+
OnDrop: { event: 'drop', modifiers: '__prevent' },
|
|
113
|
+
OnTouchStart: { event: 'touchstart' },
|
|
114
|
+
OnTouchEnd: { event: 'touchend' },
|
|
115
|
+
OnTouchMove: { event: 'touchmove', modifiers: THROTTLE },
|
|
116
|
+
OnTouchCancel: { event: 'touchcancel' },
|
|
117
|
+
OnAnimationStart: { event: 'animationstart' },
|
|
118
|
+
OnAnimationEnd: { event: 'animationend' },
|
|
119
|
+
OnAnimationIteration: { event: 'animationiteration' },
|
|
120
|
+
OnTransitionEnd: { event: 'transitionend' },
|
|
121
|
+
OnLoad: { event: 'load' },
|
|
122
|
+
OnError: { event: 'error' },
|
|
123
|
+
OnPlay: { event: 'play' },
|
|
124
|
+
OnPause: { event: 'pause' },
|
|
125
|
+
OnEnded: { event: 'ended' },
|
|
126
|
+
OnSelect: { event: 'select' },
|
|
127
|
+
};
|
|
128
|
+
const VALUE_EVENTS = {
|
|
129
|
+
OnInput: 'input',
|
|
130
|
+
OnChange: 'change',
|
|
131
|
+
};
|
|
132
|
+
/** Keyboard handlers returning a Message for every key. */
|
|
133
|
+
const KEY_EVENTS = {
|
|
134
|
+
OnKeyDown: 'keydown',
|
|
135
|
+
OnKeyDownSelf: 'keydown',
|
|
136
|
+
OnKeyUp: 'keyup',
|
|
137
|
+
OnKeyPress: 'keypress',
|
|
138
|
+
};
|
|
139
|
+
/** Keyboard handlers returning `Option<Message>` and preventing the default
|
|
140
|
+
* for the keys they handle. The browser cannot ask the server synchronously,
|
|
141
|
+
* but views are pure, so the handler is probed at render time with every
|
|
142
|
+
* common key; the page listens for exactly the keys that produced a Message. */
|
|
143
|
+
const KEY_OPTION_EVENTS = {
|
|
144
|
+
OnKeyDownPreventDefault: 'keydown',
|
|
145
|
+
OnKeyDownSelfPreventDefault: 'keydown',
|
|
146
|
+
OnKeyUpPreventDefault: 'keyup',
|
|
147
|
+
};
|
|
148
|
+
/** File events: the files are uploaded as multipart, not posted as JSON. The
|
|
149
|
+
* sequence number is taken here, so an upload keeps its place among the
|
|
150
|
+
* page's events. */
|
|
151
|
+
const FILE_EVENTS = {
|
|
152
|
+
OnFileChange: { event: 'change', files: 'evt.target.files' },
|
|
153
|
+
OnDropFiles: { event: 'drop__prevent', files: 'evt.dataTransfer?.files' },
|
|
154
|
+
};
|
|
155
|
+
/** Pointer events, posted with their data as an argument array. The array
|
|
156
|
+
* layout is the same for all; each handler picks its own arguments. */
|
|
157
|
+
const POINTER_ARGS = 'x: [evt.pointerType, evt.button, evt.screenX, evt.screenY, evt.timeStamp, evt.clientX, evt.clientY, evt.pointerId]';
|
|
158
|
+
const POINTER_EVENTS = {
|
|
159
|
+
OnPointerDown: {
|
|
160
|
+
event: 'pointerdown',
|
|
161
|
+
call: (f, [type, button, screenX, screenY, time, clientX, clientY, id]) => f(type, button, screenX, screenY, time, clientX, clientY, id, null),
|
|
162
|
+
},
|
|
163
|
+
OnPointerUp: {
|
|
164
|
+
event: 'pointerup',
|
|
165
|
+
call: (f, [type, , screenX, screenY, time]) => f(screenX, screenY, type, time),
|
|
166
|
+
},
|
|
167
|
+
OnPointerMove: {
|
|
168
|
+
event: 'pointermove',
|
|
169
|
+
modifiers: '__throttle.50ms',
|
|
170
|
+
call: (f, [type, , screenX, screenY]) => f(screenX, screenY, type),
|
|
171
|
+
},
|
|
172
|
+
OnPointerLeave: {
|
|
173
|
+
event: 'pointerleave',
|
|
174
|
+
call: (f, [type]) => f(type),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
const PROBED_KEYS = [
|
|
178
|
+
'Enter',
|
|
179
|
+
'Escape',
|
|
180
|
+
' ',
|
|
181
|
+
'Tab',
|
|
182
|
+
'ArrowUp',
|
|
183
|
+
'ArrowDown',
|
|
184
|
+
'ArrowLeft',
|
|
185
|
+
'ArrowRight',
|
|
186
|
+
'Home',
|
|
187
|
+
'End',
|
|
188
|
+
'PageUp',
|
|
189
|
+
'PageDown',
|
|
190
|
+
'Backspace',
|
|
191
|
+
'Delete',
|
|
192
|
+
// Printable ASCII, except characters Datastar treats as expression syntax.
|
|
193
|
+
...Array.from({ length: 94 }, (_, index) => String.fromCharCode(33 + index)).filter(key => key !== '$' && key !== '@'),
|
|
194
|
+
];
|
|
195
|
+
const NO_MODIFIERS = {
|
|
196
|
+
shiftKey: false,
|
|
197
|
+
ctrlKey: false,
|
|
198
|
+
altKey: false,
|
|
199
|
+
metaKey: false,
|
|
200
|
+
};
|
|
201
|
+
const INPUT_DEBOUNCE = '__debounce.200ms';
|
|
202
|
+
const SCROLL_THROTTLE = '__throttle.150ms';
|
|
203
|
+
const clickModifiers = (options) => (options?.defaultAction === 'Prevent' ? '__prevent' : '') +
|
|
204
|
+
(options?.propagation === 'Stop' ? '__stop' : '');
|
|
205
|
+
/** Every post also carries `p`: values typed into client-owned inputs whose
|
|
206
|
+
* debounced post has not gone out yet (collected by the page script), so a
|
|
207
|
+
* submit can never overtake the last keystrokes. */
|
|
208
|
+
const post = (payload) => `@post($${ENDPOINT_SIGNAL}, {payload: {${payload}, q: ++$${SEQUENCE_SIGNAL}, p: window.${PENDING_INPUTS}?.() ?? {}}, requestCancellation: 'disabled'})`;
|
|
209
|
+
const KEY_PAYLOAD = 'k: evt.key, s: evt.shiftKey, c: evt.ctrlKey, a: evt.altKey, mt: evt.metaKey';
|
|
210
|
+
const CHILD_ATTRIBUTE_BRAND = '__childAttribute';
|
|
211
|
+
const tagOf = (attribute) => typeof attribute === 'object' && attribute !== null && '_tag' in attribute
|
|
212
|
+
? String(attribute._tag)
|
|
213
|
+
: undefined;
|
|
214
|
+
const valueOf = (attributes, tag) => {
|
|
215
|
+
const found = attributes.find(attribute => tagOf(attribute) === tag);
|
|
216
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
217
|
+
return found === undefined ? undefined : found.value;
|
|
218
|
+
};
|
|
219
|
+
/** Rewrites one element's attributes. */
|
|
220
|
+
export const rewriteAttributes = (attributes, binder, h, rootBinder = binder) => {
|
|
221
|
+
const id = valueOf(attributes, 'Id');
|
|
222
|
+
const value = valueOf(attributes, 'Value');
|
|
223
|
+
const sendsValue = attributes.some(attribute => {
|
|
224
|
+
const tag = tagOf(attribute);
|
|
225
|
+
return tag !== undefined && tag in VALUE_EVENTS;
|
|
226
|
+
});
|
|
227
|
+
const inputId = id !== undefined && value !== undefined && sendsValue ? id : undefined;
|
|
228
|
+
// Remember the id of the input's value handler, for the pending-input flush.
|
|
229
|
+
let valueHandlerId;
|
|
230
|
+
const elementBinder = inputId === undefined
|
|
231
|
+
? binder
|
|
232
|
+
: {
|
|
233
|
+
...binder,
|
|
234
|
+
registerHandler: handler => {
|
|
235
|
+
const handlerId = binder.registerHandler(handler);
|
|
236
|
+
if (handler._tag === 'Value')
|
|
237
|
+
valueHandlerId ??= handlerId;
|
|
238
|
+
return handlerId;
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
const rewritten = withLocalEffects(attributes, attributes.map(attribute => rewriteAttribute(attribute, elementBinder, h, inputId, rootBinder)), h);
|
|
242
|
+
if (inputId === undefined || value === undefined) {
|
|
243
|
+
return rewritten;
|
|
244
|
+
}
|
|
245
|
+
binder.registerInput(inputId, value);
|
|
246
|
+
return [
|
|
247
|
+
...rewritten,
|
|
248
|
+
h.Attribute(`data-bind:${inputSignal(inputId).key}`, ''),
|
|
249
|
+
h.Attribute('data-preserve-attr', 'value'),
|
|
250
|
+
...(valueHandlerId === undefined
|
|
251
|
+
? []
|
|
252
|
+
: [h.Attribute(INPUT_HANDLER_ATTRIBUTE, valueHandlerId)]),
|
|
253
|
+
];
|
|
254
|
+
};
|
|
255
|
+
/** An element with local effects (`local.ts`): each binding of an event that
|
|
256
|
+
* has effects runs them first, with the sequence number its post takes. */
|
|
257
|
+
const withLocalEffects = (original, rewritten, h) => {
|
|
258
|
+
const declared = original.find(attribute => tagOf(attribute) === 'Attribute' && keyOf(attribute) === LOCAL_ATTRIBUTE);
|
|
259
|
+
if (declared === undefined)
|
|
260
|
+
return rewritten;
|
|
261
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
262
|
+
const events = Object.keys(JSON.parse(declared.value));
|
|
263
|
+
return rewritten.map(attribute => {
|
|
264
|
+
const key = tagOf(attribute) === 'Attribute' ? keyOf(attribute) : undefined;
|
|
265
|
+
const event = key === undefined ? undefined : eventOfBinding(key);
|
|
266
|
+
if (key === undefined || event === undefined || !events.includes(event))
|
|
267
|
+
return attribute;
|
|
268
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
269
|
+
const expression = attribute.value;
|
|
270
|
+
return h.Attribute(key, `(window.${LOCAL}?.(el, evt, $${SEQUENCE_SIGNAL} + 1), ${expression})`);
|
|
271
|
+
});
|
|
272
|
+
};
|
|
273
|
+
const keyOf = (attribute) => typeof attribute === 'object' && attribute !== null && 'key' in attribute
|
|
274
|
+
? String(attribute.key)
|
|
275
|
+
: undefined;
|
|
276
|
+
/** Lifecycle hooks that only exist in a browser runtime (focus management,
|
|
277
|
+
* measuring, third-party widgets). The server drops them; a widget that
|
|
278
|
+
* depends on them belongs in an island. */
|
|
279
|
+
const BROWSER_ONLY = new Set(['OnMount', 'OnUnmount']);
|
|
280
|
+
const warnedBrowserOnly = new Set();
|
|
281
|
+
const dropBrowserOnly = (tag, h) => {
|
|
282
|
+
if (!warnedBrowserOnly.has(tag)) {
|
|
283
|
+
warnedBrowserOnly.add(tag);
|
|
284
|
+
console.warn(`hyper-effect: dropped ${tag}; it needs a browser runtime. Use an island where it matters.`);
|
|
285
|
+
}
|
|
286
|
+
return h.Attribute('data-hyper-dropped', tag);
|
|
287
|
+
};
|
|
288
|
+
/** Applies a ChildAttribute's `toParentMessage` chain (innermost first) to
|
|
289
|
+
* whatever its event attribute produces, giving an attribute in the root
|
|
290
|
+
* Message universe. */
|
|
291
|
+
const liftToRoot = (attribute, mappers) => {
|
|
292
|
+
const lift = (message) => mappers.reduce((current, toParent) => toParent(current), message);
|
|
293
|
+
// OnKeyDownFocus produces `{ focusSelector, message }`; lift the message.
|
|
294
|
+
const liftValue = (value) => attribute['_tag'] === 'OnKeyDownFocus' && typeof value === 'object' && value !== null
|
|
295
|
+
? { ...value, message: lift(value.message) }
|
|
296
|
+
: lift(value);
|
|
297
|
+
const liftResult = (result) => Option.isOption(result) ? Option.map(result, liftValue) : lift(result);
|
|
298
|
+
const f = attribute['f'];
|
|
299
|
+
const maybeCustom = attribute['maybeCustomEventMessage'];
|
|
300
|
+
return {
|
|
301
|
+
...attribute,
|
|
302
|
+
...('message' in attribute ? { message: lift(attribute['message']) } : {}),
|
|
303
|
+
...(Option.isOption(maybeCustom) ? { maybeCustomEventMessage: Option.map(maybeCustom, lift) } : {}),
|
|
304
|
+
...(typeof f === 'function'
|
|
305
|
+
? { f: (...args) => liftResult(f(...args)) }
|
|
306
|
+
: {}),
|
|
307
|
+
};
|
|
308
|
+
};
|
|
309
|
+
export const rewriteAttribute = (attribute, binder, h, inputId, rootBinder = binder) => {
|
|
310
|
+
if (typeof attribute === 'object' && attribute !== null && CHILD_ATTRIBUTE_BRAND in attribute) {
|
|
311
|
+
// A Submodel's attribute placed on an element its parent builds. Its
|
|
312
|
+
// Message is the child's; lift it to the root and bind it there.
|
|
313
|
+
const inner = tagOf(attribute.attribute);
|
|
314
|
+
if (inner === undefined || !(inner.startsWith('On') || inner === 'AllowDrop')) {
|
|
315
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
316
|
+
return attribute.attribute;
|
|
317
|
+
}
|
|
318
|
+
if (BROWSER_ONLY.has(inner)) {
|
|
319
|
+
return dropBrowserOnly(inner, h);
|
|
320
|
+
}
|
|
321
|
+
const lifted = liftToRoot(
|
|
322
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
323
|
+
attribute.attribute, attribute.boundaryMappers);
|
|
324
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
325
|
+
return rewriteAttribute(lifted, rootBinder, h, inputId);
|
|
326
|
+
}
|
|
327
|
+
const tag = tagOf(attribute);
|
|
328
|
+
if (tag === 'AllowDrop') {
|
|
329
|
+
return h.Attribute('data-on:dragover__prevent', 'void 0');
|
|
330
|
+
}
|
|
331
|
+
if (tag === undefined || !tag.startsWith('On')) {
|
|
332
|
+
return attribute;
|
|
333
|
+
}
|
|
334
|
+
if (BROWSER_ONLY.has(tag)) {
|
|
335
|
+
return dropBrowserOnly(tag, h);
|
|
336
|
+
}
|
|
337
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
338
|
+
const event = attribute;
|
|
339
|
+
const bindEvent = (name, expression) => h.Attribute(`data-on:${name}`, expression);
|
|
340
|
+
const messageEvent = MESSAGE_EVENTS[tag];
|
|
341
|
+
if (messageEvent !== undefined && event.message !== undefined) {
|
|
342
|
+
const modifiers = tag === 'OnClick' ? clickModifiers(event.options) : (messageEvent.modifiers ?? '');
|
|
343
|
+
const guard = messageEvent.guard === undefined ? '' : `${messageEvent.guard} && `;
|
|
344
|
+
return bindEvent(`${messageEvent.event}${modifiers}`, `${guard}${post(binder.bindMessage(event.message))}`);
|
|
345
|
+
}
|
|
346
|
+
if (tag === 'OnCancelPreventDefault' && event.maybeCustomEventMessage !== undefined) {
|
|
347
|
+
return bindEvent('cancel__prevent', Option.match(event.maybeCustomEventMessage, {
|
|
348
|
+
onNone: () => 'void 0',
|
|
349
|
+
onSome: message => post(binder.bindMessage(message)),
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
if (tag === 'OnClickFocus' && event.message !== undefined && event.focusSelector !== undefined) {
|
|
353
|
+
return bindEvent('click', `(window.${FOCUS}?.(${literal(event.focusSelector)}), ${post(binder.bindMessage(event.message))})`);
|
|
354
|
+
}
|
|
355
|
+
if (tag === 'OnCopyText' && event.text !== undefined) {
|
|
356
|
+
return bindEvent('copy', `window.${COPY_TEXT}?.(evt, ${literal(event.text)})`);
|
|
357
|
+
}
|
|
358
|
+
if (tag === 'OnCutText' && event.text !== undefined && event.message !== undefined) {
|
|
359
|
+
return bindEvent('cut', `(window.${COPY_TEXT}?.(evt, ${literal(event.text)}), ${post(binder.bindMessage(event.message))})`);
|
|
360
|
+
}
|
|
361
|
+
if (event.f === undefined) {
|
|
362
|
+
throw new UnsupportedEventError(tag);
|
|
363
|
+
}
|
|
364
|
+
const valueEvent = VALUE_EVENTS[tag];
|
|
365
|
+
if (valueEvent !== undefined) {
|
|
366
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
367
|
+
const f = event.f;
|
|
368
|
+
const id = binder.registerHandler(inputId === undefined ? { _tag: 'Value', f } : { _tag: 'Value', f, inputId });
|
|
369
|
+
return bindEvent(`${valueEvent}${tag === 'OnInput' ? INPUT_DEBOUNCE : ''}`,
|
|
370
|
+
// A value that already went out with another event (see `p`) is not
|
|
371
|
+
// sent again: a late duplicate would undo what the server did since.
|
|
372
|
+
`window.${FRESH_VALUE}?.(el) !== false && ${post(`h: '${id}', v: evt.target.value`)}`);
|
|
373
|
+
}
|
|
374
|
+
const keyEvent = KEY_EVENTS[tag];
|
|
375
|
+
if (keyEvent !== undefined) {
|
|
376
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
377
|
+
const f = event.f;
|
|
378
|
+
const id = binder.registerHandler({
|
|
379
|
+
_tag: 'Key',
|
|
380
|
+
f: (key, modifiers) => Option.some(f(key, modifiers)),
|
|
381
|
+
});
|
|
382
|
+
const self = tag === 'OnKeyDownSelf' ? 'evt.target === el && ' : '';
|
|
383
|
+
return bindEvent(keyEvent, `${self}${post(`h: '${id}', ${KEY_PAYLOAD}`)}`);
|
|
384
|
+
}
|
|
385
|
+
const keyOptionEvent = KEY_OPTION_EVENTS[tag];
|
|
386
|
+
if (keyOptionEvent !== undefined) {
|
|
387
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
388
|
+
const f = event.f;
|
|
389
|
+
const keys = PROBED_KEYS.filter(key => Option.isSome(f(key, NO_MODIFIERS)));
|
|
390
|
+
if (keys.length === 0) {
|
|
391
|
+
return h.Attribute('data-hyper-no-keys', '');
|
|
392
|
+
}
|
|
393
|
+
const id = binder.registerHandler({ _tag: 'Key', f });
|
|
394
|
+
const self = tag === 'OnKeyDownSelfPreventDefault' ? 'evt.target === el && ' : '';
|
|
395
|
+
return bindEvent(keyOptionEvent, `${self}${JSON.stringify(keys)}.includes(evt.key) && (evt.preventDefault(), ${post(`h: '${id}', ${KEY_PAYLOAD}`)})`);
|
|
396
|
+
}
|
|
397
|
+
if (tag === 'OnKeyDownFocus') {
|
|
398
|
+
// Probing also yields where focus goes for each key, so the browser moves
|
|
399
|
+
// focus at once and the server only learns the Message.
|
|
400
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
401
|
+
const f = event.f;
|
|
402
|
+
const targets = {};
|
|
403
|
+
for (const key of PROBED_KEYS) {
|
|
404
|
+
const result = f(key, NO_MODIFIERS);
|
|
405
|
+
if (Option.isSome(result))
|
|
406
|
+
targets[key] = result.value.focusSelector;
|
|
407
|
+
}
|
|
408
|
+
if (Object.keys(targets).length === 0) {
|
|
409
|
+
return h.Attribute('data-hyper-no-keys', '');
|
|
410
|
+
}
|
|
411
|
+
const id = binder.registerHandler({
|
|
412
|
+
_tag: 'Key',
|
|
413
|
+
f: (key, modifiers) => Option.map(f(key, modifiers), result => result.message),
|
|
414
|
+
});
|
|
415
|
+
return bindEvent('keydown', `window.${FOCUS_KEY}?.(evt, ${literal(targets)}) && ${post(`h: '${id}', ${KEY_PAYLOAD}`)}`);
|
|
416
|
+
}
|
|
417
|
+
const pointerEvent = POINTER_EVENTS[tag];
|
|
418
|
+
if (pointerEvent !== undefined) {
|
|
419
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
420
|
+
const f = event.f;
|
|
421
|
+
const id = binder.registerHandler({
|
|
422
|
+
_tag: 'Args',
|
|
423
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
424
|
+
f: args => pointerEvent.call(f, args),
|
|
425
|
+
});
|
|
426
|
+
return bindEvent(`${pointerEvent.event}${pointerEvent.modifiers ?? ''}`, post(`h: '${id}', ${POINTER_ARGS}`));
|
|
427
|
+
}
|
|
428
|
+
if (tag === 'OnBeforeInput') {
|
|
429
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
430
|
+
const f = event.f;
|
|
431
|
+
const id = binder.registerHandler({
|
|
432
|
+
_tag: 'Args',
|
|
433
|
+
f: ([inputType, data]) => Option.some(f(String(inputType), Option.fromNullishOr(data))),
|
|
434
|
+
});
|
|
435
|
+
return bindEvent('beforeinput', post(`h: '${id}', x: [evt.inputType, evt.data]`));
|
|
436
|
+
}
|
|
437
|
+
if (tag === 'OnToggle') {
|
|
438
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
439
|
+
const f = event.f;
|
|
440
|
+
const id = binder.registerHandler({ _tag: 'Number', f: value => f(value === 1) });
|
|
441
|
+
return bindEvent('toggle', post(`h: '${id}', n: el.open ? 1 : 0`));
|
|
442
|
+
}
|
|
443
|
+
if (tag === 'OnScroll') {
|
|
444
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
445
|
+
const f = event.f;
|
|
446
|
+
const id = binder.registerHandler({ _tag: 'Number', f });
|
|
447
|
+
return bindEvent(`scroll${SCROLL_THROTTLE}`, post(`h: '${id}', n: el.scrollTop`));
|
|
448
|
+
}
|
|
449
|
+
const fileEvent = FILE_EVENTS[tag];
|
|
450
|
+
if (fileEvent !== undefined) {
|
|
451
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
452
|
+
const f = event.f;
|
|
453
|
+
const id = binder.registerHandler({ _tag: 'Files', f });
|
|
454
|
+
return bindEvent(fileEvent.event,
|
|
455
|
+
// The handler id is written `h: '<id>'`, as in every other binding.
|
|
456
|
+
`window.${UPLOAD}?.(el, ${fileEvent.files}, {h: '${id}'}, ++$${SEQUENCE_SIGNAL}, $${ENDPOINT_SIGNAL})`);
|
|
457
|
+
}
|
|
458
|
+
if (tag === 'OnCustomEvent' && event.name !== undefined) {
|
|
459
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
460
|
+
const f = event.f;
|
|
461
|
+
const id = binder.registerHandler({ _tag: 'Custom', name: event.name, f });
|
|
462
|
+
return bindEvent(event.name, post(`h: '${id}', d: evt.detail`));
|
|
463
|
+
}
|
|
464
|
+
throw new UnsupportedEventError(tag);
|
|
465
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Html, HtmlBuilder } from 'foldkit/html';
|
|
2
|
+
import { type Binder } from './binding.ts';
|
|
3
|
+
/** Runs `f` where a server builder may be used but nothing it builds is kept.
|
|
4
|
+
* Event handlers run at event time, outside any render, and some call view
|
|
5
|
+
* code: crease's menus resolve their items, icons included, to find which
|
|
6
|
+
* items a key can move to. */
|
|
7
|
+
export declare const withDiscardedRender: <A>(f: () => A) => A;
|
|
8
|
+
/** Renders `view` with `binder` as the page's binder. */
|
|
9
|
+
export declare const withBinder: <Message, A>(binder: Binder<Message>, render: () => A) => A;
|
|
10
|
+
/** The binder of the render in progress, when `h` is a server builder.
|
|
11
|
+
* Server-aware helpers (islands) use it to bind events themselves. */
|
|
12
|
+
export declare const binderOf: <Message>(h: HtmlBuilder<Message>) => Binder<Message> | undefined;
|
|
13
|
+
/** The server builder for a Foldkit builder; use it inside `withBinder`. */
|
|
14
|
+
export declare const serverBuilder: <Message>(base: HtmlBuilder<Message>) => HtmlBuilder<Message>;
|
|
15
|
+
/** Convenience for one-off renders (tests): the server builder bound to
|
|
16
|
+
* `binder` for the duration of `render`. */
|
|
17
|
+
export declare const renderWith: <Message, A>(base: HtmlBuilder<Message>, binder: Binder<Message>, render: (h: HtmlBuilder<Message>) => A) => A;
|
|
18
|
+
/** Foldkit's serializer silently skips DOM listeners. A listener left on a
|
|
19
|
+
* vnode means an element was built outside the server builder, and its
|
|
20
|
+
* behavior would vanish from the page, so fail loudly instead. */
|
|
21
|
+
export declare const assertNoDroppedHandlers: (root: Html) => void;
|