@typed/ui 1.0.0-beta.0 → 1.0.0-beta.10

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.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +78 -26
  3. package/dist/Alert.d.ts +72 -0
  4. package/dist/Alert.d.ts.map +1 -0
  5. package/dist/Alert.js +40 -0
  6. package/dist/Button.d.ts +100 -0
  7. package/dist/Button.d.ts.map +1 -0
  8. package/dist/Button.js +42 -0
  9. package/dist/Carousel.d.ts +346 -0
  10. package/dist/Carousel.d.ts.map +1 -0
  11. package/dist/Carousel.js +264 -0
  12. package/dist/Checkbox.d.ts +168 -0
  13. package/dist/Checkbox.d.ts.map +1 -0
  14. package/dist/Checkbox.js +146 -0
  15. package/dist/Collection.d.ts +254 -0
  16. package/dist/Collection.d.ts.map +1 -0
  17. package/dist/Collection.js +218 -0
  18. package/dist/Combobox.d.ts +367 -0
  19. package/dist/Combobox.d.ts.map +1 -0
  20. package/dist/Combobox.js +300 -0
  21. package/dist/Composite.d.ts +823 -0
  22. package/dist/Composite.d.ts.map +1 -0
  23. package/dist/Composite.js +615 -0
  24. package/dist/Dialog.d.ts +544 -0
  25. package/dist/Dialog.d.ts.map +1 -0
  26. package/dist/Dialog.js +357 -0
  27. package/dist/Disclosure.d.ts +219 -0
  28. package/dist/Disclosure.d.ts.map +1 -0
  29. package/dist/Disclosure.js +128 -0
  30. package/dist/Dom/Events.d.ts +122 -0
  31. package/dist/Dom/Events.d.ts.map +1 -0
  32. package/dist/Dom/Events.js +192 -0
  33. package/dist/Dom/Props.d.ts +161 -0
  34. package/dist/Dom/Props.d.ts.map +1 -0
  35. package/dist/Dom/Props.js +110 -0
  36. package/dist/Dom/Refs.d.ts +58 -0
  37. package/dist/Dom/Refs.d.ts.map +1 -0
  38. package/dist/Dom/Refs.js +61 -0
  39. package/dist/Dom/Render.d.ts +59 -0
  40. package/dist/Dom/Render.d.ts.map +1 -0
  41. package/dist/Dom/Render.js +71 -0
  42. package/dist/Dom/Types.d.ts +570 -0
  43. package/dist/Dom/Types.d.ts.map +1 -0
  44. package/dist/Dom/Types.js +1 -0
  45. package/dist/Dom/index.d.ts +20 -0
  46. package/dist/Dom/index.d.ts.map +1 -0
  47. package/dist/Dom/index.js +8 -0
  48. package/dist/Dom.d.ts +14 -0
  49. package/dist/Dom.d.ts.map +1 -0
  50. package/dist/Dom.js +13 -0
  51. package/dist/Focusable.d.ts +85 -0
  52. package/dist/Focusable.d.ts.map +1 -0
  53. package/dist/Focusable.js +35 -0
  54. package/dist/Form.d.ts +1727 -0
  55. package/dist/Form.d.ts.map +1 -0
  56. package/dist/Form.js +1142 -0
  57. package/dist/Grid.d.ts +388 -0
  58. package/dist/Grid.d.ts.map +1 -0
  59. package/dist/Grid.js +284 -0
  60. package/dist/Group.d.ts +128 -0
  61. package/dist/Group.d.ts.map +1 -0
  62. package/dist/Group.js +71 -0
  63. package/dist/Heading.d.ts +87 -0
  64. package/dist/Heading.d.ts.map +1 -0
  65. package/dist/Heading.js +58 -0
  66. package/dist/Hovercard.d.ts +297 -0
  67. package/dist/Hovercard.d.ts.map +1 -0
  68. package/dist/Hovercard.js +188 -0
  69. package/dist/HttpRouter.d.ts +129 -6
  70. package/dist/HttpRouter.d.ts.map +1 -1
  71. package/dist/HttpRouter.js +199 -56
  72. package/dist/Link.d.ts +63 -28
  73. package/dist/Link.d.ts.map +1 -1
  74. package/dist/Link.js +84 -37
  75. package/dist/Listbox.d.ts +305 -0
  76. package/dist/Listbox.d.ts.map +1 -0
  77. package/dist/Listbox.js +245 -0
  78. package/dist/Menu.d.ts +663 -0
  79. package/dist/Menu.d.ts.map +1 -0
  80. package/dist/Menu.js +569 -0
  81. package/dist/Menubar.d.ts +249 -0
  82. package/dist/Menubar.d.ts.map +1 -0
  83. package/dist/Menubar.js +207 -0
  84. package/dist/Meter.d.ts +157 -0
  85. package/dist/Meter.d.ts.map +1 -0
  86. package/dist/Meter.js +87 -0
  87. package/dist/NativeDetails.d.ts +41 -0
  88. package/dist/NativeDetails.d.ts.map +1 -0
  89. package/dist/NativeDetails.js +40 -0
  90. package/dist/NativeDialog.d.ts +66 -0
  91. package/dist/NativeDialog.d.ts.map +1 -0
  92. package/dist/NativeDialog.js +88 -0
  93. package/dist/NativePopover.d.ts +43 -0
  94. package/dist/NativePopover.d.ts.map +1 -0
  95. package/dist/NativePopover.js +84 -0
  96. package/dist/Popover.d.ts +240 -0
  97. package/dist/Popover.d.ts.map +1 -0
  98. package/dist/Popover.js +140 -0
  99. package/dist/RadioGroup.d.ts +330 -0
  100. package/dist/RadioGroup.d.ts.map +1 -0
  101. package/dist/RadioGroup.js +241 -0
  102. package/dist/Role.d.ts +64 -0
  103. package/dist/Role.d.ts.map +1 -0
  104. package/dist/Role.js +27 -0
  105. package/dist/Select.d.ts +418 -0
  106. package/dist/Select.d.ts.map +1 -0
  107. package/dist/Select.js +357 -0
  108. package/dist/Separator.d.ts +58 -0
  109. package/dist/Separator.d.ts.map +1 -0
  110. package/dist/Separator.js +32 -0
  111. package/dist/Slider.d.ts +141 -0
  112. package/dist/Slider.d.ts.map +1 -0
  113. package/dist/Slider.js +101 -0
  114. package/dist/SpinButton.d.ts +141 -0
  115. package/dist/SpinButton.d.ts.map +1 -0
  116. package/dist/SpinButton.js +101 -0
  117. package/dist/Storybook.d.ts +76 -0
  118. package/dist/Storybook.d.ts.map +1 -0
  119. package/dist/Storybook.js +102 -0
  120. package/dist/Switch.d.ts +148 -0
  121. package/dist/Switch.d.ts.map +1 -0
  122. package/dist/Switch.js +110 -0
  123. package/dist/Tab.d.ts +26 -0
  124. package/dist/Tab.d.ts.map +1 -0
  125. package/dist/Tab.js +25 -0
  126. package/dist/Tabs.d.ts +411 -0
  127. package/dist/Tabs.d.ts.map +1 -0
  128. package/dist/Tabs.js +262 -0
  129. package/dist/Toolbar.d.ts +248 -0
  130. package/dist/Toolbar.d.ts.map +1 -0
  131. package/dist/Toolbar.js +187 -0
  132. package/dist/Tooltip.d.ts +296 -0
  133. package/dist/Tooltip.d.ts.map +1 -0
  134. package/dist/Tooltip.js +172 -0
  135. package/dist/Tree.d.ts +405 -0
  136. package/dist/Tree.d.ts.map +1 -0
  137. package/dist/Tree.js +333 -0
  138. package/dist/TreeGrid.d.ts +426 -0
  139. package/dist/TreeGrid.d.ts.map +1 -0
  140. package/dist/TreeGrid.js +308 -0
  141. package/dist/VisuallyHidden.d.ts +68 -0
  142. package/dist/VisuallyHidden.d.ts.map +1 -0
  143. package/dist/VisuallyHidden.js +44 -0
  144. package/dist/WindowSplitter.d.ts +336 -0
  145. package/dist/WindowSplitter.d.ts.map +1 -0
  146. package/dist/WindowSplitter.js +305 -0
  147. package/dist/index.d.ts +48 -0
  148. package/dist/index.d.ts.map +1 -1
  149. package/dist/index.js +48 -0
  150. package/package.json +47 -14
  151. package/src/HttpRouter.test.ts +0 -294
  152. package/src/HttpRouter.ts +0 -168
  153. package/src/Link.test.ts +0 -85
  154. package/src/Link.ts +0 -107
  155. package/src/index.ts +0 -2
  156. package/tsconfig.json +0 -6
@@ -0,0 +1,188 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Schema from "effect/Schema";
3
+ import { RefSubject } from "@typed/fx";
4
+ import { EventHandler, html, } from "@typed/template";
5
+ import * as Dom from "./Dom.js";
6
+ import * as NativePopover from "./NativePopover.js";
7
+ /** Schema for hovercard hydration state.
8
+ * @remarks
9
+ * ## Why
10
+ * Shared identity/open encoding keeps SSR and browser state compatible.
11
+ * ## Ownership and lifetime
12
+ * The immutable schema acquires no resources.
13
+ * @since 1.0.0
14
+ * @category Open state
15
+ */
16
+ export const StateSchema = Schema.Struct({ id: Schema.String, open: Schema.Boolean });
17
+ /** Creates hydrated hovercard state.
18
+ * @remarks
19
+ * ## Why
20
+ * Visibility and identity can be tested without mounting UI.
21
+ * ## Ownership and lifetime
22
+ * The calling Effect Scope owns the returned RefSubject.
23
+ * @example
24
+ * ```ts
25
+ * import { Effect } from "effect"
26
+ * import * as Hovercard from "@typed/ui/Hovercard"
27
+ *
28
+ * const program = Effect.gen(function* () {
29
+ * return yield* Hovercard.makeState({ id: "author-card" })
30
+ * })
31
+ * ```
32
+ * @since 1.0.0
33
+ * @category Open state
34
+ */
35
+ export function makeState(initial) {
36
+ return RefSubject.hydrate(StateSchema, { id: initial.id, open: initial.open ?? false });
37
+ }
38
+ /** Sets hovercard visibility.
39
+ * @remarks
40
+ * ## Why
41
+ * Delayed focus/hover flows converge on one atomic state update.
42
+ * ## Ownership and lifetime
43
+ * The Effect uses the existing RefSubject lifetime and acquires no resource.
44
+ * @example
45
+ * ```ts
46
+ * import { Effect } from "effect"
47
+ * import * as Hovercard from "@typed/ui/Hovercard"
48
+ *
49
+ * const program = Effect.gen(function* () {
50
+ * const state = yield* Hovercard.makeState({ id: "author-card" })
51
+ * yield* Hovercard.setOpen(state, true)
52
+ * })
53
+ * ```
54
+ * @since 1.0.0
55
+ * @category Open state
56
+ */
57
+ export function setOpen(state, open) {
58
+ return RefSubject.update(state, (current) => ({ ...current, open }));
59
+ }
60
+ const scheduleVersions = new WeakMap();
61
+ const scheduleOpen = Effect.fn(function* (state, open, delay) {
62
+ const version = (scheduleVersions.get(state) ?? 0) + 1;
63
+ scheduleVersions.set(state, version);
64
+ if (delay > 0)
65
+ yield* Effect.sleep(delay);
66
+ if (scheduleVersions.get(state) === version)
67
+ yield* setOpen(state, open);
68
+ });
69
+ function anchorInternalProps(options) {
70
+ const id = RefSubject.map(options.state, (state) => state.id);
71
+ return () => ({
72
+ "aria-controls": id,
73
+ onfocus: scheduleOpen(options.state, true, options.showDelay ?? 0),
74
+ onblur: EventHandler.make(Effect.fn(function* (event) {
75
+ const contentId = (yield* options.state).id;
76
+ const content = Dom.currentTarget(event).ownerDocument.getElementById(contentId);
77
+ if (event.relatedTarget instanceof Node && content?.contains(event.relatedTarget))
78
+ return;
79
+ yield* scheduleOpen(options.state, false, options.hideDelay ?? 0);
80
+ })),
81
+ onkeydown: EventHandler.make(Effect.fn(function* (event) {
82
+ if (event.key === "Escape")
83
+ yield* scheduleOpen(options.state, false, 0);
84
+ })),
85
+ onmouseenter: scheduleOpen(options.state, true, options.showDelay ?? 0),
86
+ onmouseleave: scheduleOpen(options.state, false, options.hideDelay ?? 0),
87
+ });
88
+ }
89
+ /** Renders an anchor coordinating pointer and optional host-focus behavior.
90
+ * @remarks
91
+ * ## Why
92
+ * The default host is a non-focusable `<span>`, so it provides pointer behavior
93
+ * only. Give the span `tabindex`, or supply a natively focusable custom host,
94
+ * for direct focus, blur, and keydown handling. Unlike a tooltip, hovercard
95
+ * content may receive focus; host blur checks `relatedTarget` and stays open
96
+ * while focus moves inside the card. Nested-anchor focus does not bubble.
97
+ * ## Ownership and lifetime
98
+ * Running the Fx owns real DOM handlers and dynamic relationships in its Scope.
99
+ * Custom hosts must preserve `aria-controls` and all supplied handlers.
100
+ * @example
101
+ * ```ts
102
+ * import { Effect } from "effect"
103
+ * import * as Hovercard from "@typed/ui/Hovercard"
104
+ *
105
+ * const program = Effect.gen(function* () {
106
+ * const state = yield* Hovercard.makeState({ id: "author-card" })
107
+ * return Hovercard.Anchor({
108
+ * state,
109
+ * content: "Ada Lovelace",
110
+ * props: { tabindex: 0 }
111
+ * })
112
+ * })
113
+ * ```
114
+ * @since 1.0.0
115
+ * @category Pointer and focus anchors
116
+ */
117
+ export function Anchor(options, host) {
118
+ return Dom.renderHost()(options, host, anchorInternalProps(options), options.content, (props, content) => html `<span ...${props}>${content}</span>`);
119
+ }
120
+ function contentInternalProps(options) {
121
+ const id = RefSubject.map(options.state, (state) => state.id);
122
+ return ({ property }) => ({
123
+ id,
124
+ role: "dialog",
125
+ "aria-label": property("label", undefined),
126
+ "aria-labelledby": property("labelledBy", undefined),
127
+ popover: "manual",
128
+ onfocusin: scheduleOpen(options.state, true, 0),
129
+ onfocusout: EventHandler.make(Effect.fn(function* (event) {
130
+ const content = Dom.currentTarget(event);
131
+ if (event.relatedTarget instanceof Node && content.contains(event.relatedTarget))
132
+ return;
133
+ yield* scheduleOpen(options.state, false, 0);
134
+ })),
135
+ onmouseenter: scheduleOpen(options.state, true, 0),
136
+ onmouseleave: EventHandler.make(Effect.fn(function* (event) {
137
+ const contentId = (yield* options.state).id;
138
+ if (event.relatedTarget instanceof Element &&
139
+ event.relatedTarget.getAttribute("aria-controls") === contentId)
140
+ return;
141
+ yield* scheduleOpen(options.state, false, 0);
142
+ })),
143
+ onkeydown: EventHandler.make(Effect.fn(function* (event) {
144
+ if (event.key === "Escape")
145
+ yield* scheduleOpen(options.state, false, 0);
146
+ })),
147
+ ontoggle: EventHandler.make(Effect.fn((event) => setOpen(options.state, Dom.toggleState(event) === "open"))),
148
+ ref: Dom.composeRefs(options.state, NativePopover.ref(options.state)),
149
+ });
150
+ }
151
+ /** Renders named, focusable hovercard content in the native top layer.
152
+ * @remarks
153
+ * ## Why
154
+ * `role="dialog"` distinguishes interactive content from a tooltip. Native
155
+ * popover placement is retained, and focus/pointer transitions prevent closing
156
+ * while interaction remains inside the card.
157
+ * ## Ownership and lifetime
158
+ * Running the Fx owns handlers and NativePopover observation in its Scope. A
159
+ * custom host must preserve id, role, accessible name, manual popover,
160
+ * lifecycle handlers, and one hydration ref owner.
161
+ * @example
162
+ * ```ts
163
+ * import { Effect } from "effect"
164
+ * import * as Hovercard from "@typed/ui/Hovercard"
165
+ *
166
+ * const program = Effect.gen(function* () {
167
+ * const state = yield* Hovercard.makeState({ id: "author-card" })
168
+ * return Hovercard.Content({ state, label: "Author", content: "Mathematician" })
169
+ * })
170
+ * ```
171
+ * @since 1.0.0
172
+ * @category Native content host
173
+ */
174
+ export function Content(options, host) {
175
+ return Dom.renderHost()(options, host, contentInternalProps(options), options.content, (i, content) => {
176
+ return html `<div ...${i}>${content}</div>`;
177
+ });
178
+ }
179
+ /** Canonical widget alias for `Content`.
180
+ * @remarks
181
+ * ## Why
182
+ * The alias provides the widget name while `Content` names compound use.
183
+ * ## Ownership and lifetime
184
+ * It has exactly the same Scope and native popover ownership as `Content`.
185
+ * @since 1.0.0
186
+ * @category Native content host
187
+ */
188
+ export const Hovercard = Content;
@@ -1,13 +1,136 @@
1
1
  import * as Effect from "effect/Effect";
2
- import * as Scope from "effect/Scope";
3
- import { type HttpRouter } from "effect/unstable/http/HttpRouter";
2
+ import { type HttpRouter, type Provided as HttpRouterProvided, type Request as HttpRouterRequest } from "effect/unstable/http/HttpRouter";
3
+ import * as HttpServerError from "effect/unstable/http/HttpServerError";
4
4
  import { type Matcher, type Router } from "@typed/router";
5
+ import { type Navigation } from "@typed/navigation";
5
6
  import { type RenderEvent } from "@typed/template";
6
- type ProvidedForSsr = Scope.Scope | Router;
7
+ type ProvidedForSsr = HttpRouterProvided | Navigation | Router;
8
+ type SsrForHttpRequirement<E, R> = Exclude<R, ProvidedForSsr> | HttpRouterRequest.From<"Error", E | HttpServerError.HttpServerError>;
9
+ type SsrForHttpEffect<E, R> = Effect.Effect<void, never, SsrForHttpRequirement<E, R>>;
10
+ /**
11
+ * Registers buffered, GET-only HTML routes for a matcher.
12
+ *
13
+ * Path captures take precedence over same-named query parameters. Rendering
14
+ * completes before the response is created; this adapter does not stream.
15
+ *
16
+ * @remarks
17
+ * ## Why
18
+ *
19
+ * Typed route matchers already describe decoding, guards, parent prefixes, and
20
+ * render output. `ssrForHttp` installs that same model in Effect's HttpRouter
21
+ * and buffers one complete HTML document for runtimes that require a known body.
22
+ *
23
+ * ## Ownership and lifetime
24
+ *
25
+ * Registration adds GET routes to the supplied Effect HttpRouter. Each request
26
+ * creates request-local memory navigation and current-route services; the
27
+ * request Effect owns rendering and all finalizers. Buffered rendering must
28
+ * complete before the response is returned. Rendering HTML does not mount or
29
+ * hydrate a browser client.
30
+ *
31
+ * ## Routing behavior
32
+ *
33
+ * Matcher cases sharing a path are tried in their compiled order. Path params
34
+ * overwrite same-named query params. Decode/guard failures continue candidate
35
+ * selection; a matched renderer failure stays in the typed request error channel.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * import { ssrForHttp } from "@typed/ui/HttpRouter"
40
+ * import { Effect } from "effect"
41
+ * import * as HttpRouter from "effect/unstable/http/HttpRouter"
42
+ * import { Parse, match } from "@typed/router"
43
+ * import { html } from "@typed/template"
44
+ *
45
+ * const pages = match(Parse("/"), html`<h1>Home</h1>`)
46
+ * const routes = Effect.gen(function* () {
47
+ * const router = yield* HttpRouter.make
48
+ * yield* ssrForHttp(router, pages)
49
+ * return router
50
+ * })
51
+ * ```
52
+ *
53
+ * @since 1.0.0
54
+ * @category Buffered server rendering
55
+ */
7
56
  export declare const ssrForHttp: {
8
- <E, R>(input: Matcher<RenderEvent, E, R>): (router: HttpRouter) => Effect.Effect<void, never, Exclude<R, ProvidedForSsr>>;
9
- <E, R>(router: HttpRouter, input: Matcher<RenderEvent, E, R>): Effect.Effect<void, never, Exclude<R, ProvidedForSsr>>;
57
+ <E, R>(input: Matcher<RenderEvent, E, R>): (router: HttpRouter) => SsrForHttpEffect<E, R>;
58
+ <E, R>(router: HttpRouter, input: Matcher<RenderEvent, E, R>): SsrForHttpEffect<E, R>;
10
59
  };
11
- export declare function handleHttpServerError(router: HttpRouter): Effect.Effect<void, never, import("effect/unstable/http/HttpRouter").Request<"GlobalError", unknown>>;
60
+ /**
61
+ * Registers streaming, GET-only HTML routes for a matcher.
62
+ *
63
+ * Path captures take precedence over same-named query parameters. HTML chunks
64
+ * are emitted as they are rendered via `renderToHtml` and `HttpServerResponse.stream`.
65
+ *
66
+ * @remarks
67
+ * ## Why
68
+ *
69
+ * Streaming lets the server send ordered renderer-owned HTML chunks before the
70
+ * whole page completes while preserving the same Typed matcher and Effect HTTP model.
71
+ *
72
+ * ## Ownership and lifetime
73
+ *
74
+ * Registration adds GET routes to the supplied router. Each request owns its
75
+ * navigation/router services and stream Scope. Client cancellation interrupts
76
+ * rendering and runs finalizers. The stream carries typed renderer failures;
77
+ * producing HTML alone does not attach client hydration behavior.
78
+ *
79
+ * @example
80
+ * ```ts
81
+ * import { streamingSsrForHttp } from "@typed/ui/HttpRouter"
82
+ * import { Effect } from "effect"
83
+ * import * as HttpRouter from "effect/unstable/http/HttpRouter"
84
+ * import { Parse, match } from "@typed/router"
85
+ * import { html } from "@typed/template"
86
+ *
87
+ * const pages = match(Parse("/"), html`<h1>Streamed home</h1>`)
88
+ * const routes = Effect.gen(function* () {
89
+ * const router = yield* HttpRouter.make
90
+ * yield* streamingSsrForHttp(router, pages)
91
+ * return router
92
+ * })
93
+ * ```
94
+ *
95
+ * @since 1.0.0
96
+ * @category Streaming server rendering
97
+ */
98
+ export declare const streamingSsrForHttp: {
99
+ <E, R>(input: Matcher<RenderEvent, E, R>): (router: HttpRouter) => SsrForHttpEffect<E, R>;
100
+ <E, R>(router: HttpRouter, input: Matcher<RenderEvent, E, R>): SsrForHttpEffect<E, R>;
101
+ };
102
+ /**
103
+ * Converts Effect HTTP server errors into empty 400, 404, or 500 responses.
104
+ * Other failures remain in the global error channel.
105
+ *
106
+ * @remarks
107
+ * ## Why
108
+ *
109
+ * Effect's structured server failures have a safe transport status, whereas
110
+ * application/domain errors must remain visible for deliberate handling.
111
+ *
112
+ * ## Ownership and lifetime
113
+ *
114
+ * The middleware is installed on the supplied router and runs in each request
115
+ * lifetime. It allocates no long-lived resource and does not log, swallow, or
116
+ * reclassify non-HttpServerError failures.
117
+ *
118
+ * @example
119
+ * ```ts
120
+ * import { handleHttpServerError } from "@typed/ui/HttpRouter"
121
+ * import { Effect } from "effect"
122
+ * import * as HttpRouter from "effect/unstable/http/HttpRouter"
123
+ *
124
+ * const router = Effect.gen(function* () {
125
+ * const router = yield* HttpRouter.make
126
+ * yield* handleHttpServerError(router)
127
+ * return router
128
+ * })
129
+ * ```
130
+ *
131
+ * @since 1.0.0
132
+ * @category HTTP error responses
133
+ */
134
+ export declare function handleHttpServerError(router: HttpRouter): Effect.Effect<void, never, HttpRouterRequest<"GlobalError", unknown>>;
12
135
  export {};
13
136
  //# sourceMappingURL=HttpRouter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"HttpRouter.d.ts","sourceRoot":"","sources":["../src/HttpRouter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAKxC,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AAEtC,OAAO,EAAE,KAAK,UAAU,EAA4B,MAAM,iCAAiC,CAAC;AAK5F,OAAO,EAQL,KAAK,OAAO,EAGZ,KAAK,MAAM,EACZ,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAsB,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEvE,KAAK,cAAc,GAAG,KAAK,CAAC,KAAK,GAAG,MAAM,CAAC;AAE3C,eAAO,MAAM,UAAU,EAAE;IACvB,CAAC,CAAC,EAAE,CAAC,EACH,KAAK,EAAE,OAAO,CAAC,WAAW,EAAE,CAAC,EAAE,CAAC,CAAC,GAChC,CAAC,MAAM,EAAE,UAAU,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC,EAAE,cAAc,CAAC,CAAC,CAAC;IAClF,CAAC,CAAC,EAAE,CAAC,EACH,MAAM,EAAE,UAAU,EAClB,KAAK,EAAE,OAAO,CAAC,WAAW,EAAE,CAAC,EAAE,CAAC,CAAC,GAChC,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC,EAAE,cAAc,CAAC,CAAC,CAAC;CAY1D,CAAC;AAEH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,UAAU,yGAQvD"}
1
+ {"version":3,"file":"HttpRouter.d.ts","sourceRoot":"","sources":["../src/HttpRouter.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAOxC,OAAO,EACL,KAAK,UAAU,EACf,KAAK,QAAQ,IAAI,kBAAkB,EACnC,KAAK,OAAO,IAAI,iBAAiB,EAGlC,MAAM,iCAAiC,CAAC;AACzC,OAAO,KAAK,eAAe,MAAM,sCAAsC,CAAC;AAGxE,OAAO,EAML,KAAK,OAAO,EAKZ,KAAK,MAAM,EACZ,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAiB,KAAK,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACnE,OAAO,EAAoC,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAErF,KAAK,cAAc,GAAG,kBAAkB,GAAG,UAAU,GAAG,MAAM,CAAC;AAE/D,KAAK,qBAAqB,CAAC,CAAC,EAAE,CAAC,IAC3B,OAAO,CAAC,CAAC,EAAE,cAAc,CAAC,GAC1B,iBAAiB,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,GAAG,eAAe,CAAC,eAAe,CAAC,CAAC;AAEzE,KAAK,gBAAgB,CAAC,CAAC,EAAE,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,qBAAqB,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AA+EtF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,eAAO,MAAM,UAAU,EAAE;IACvB,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,WAAW,EAAE,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,MAAM,EAAE,UAAU,KAAK,gBAAgB,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC1F,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,OAAO,CAAC,WAAW,EAAE,CAAC,EAAE,CAAC,CAAC,GAAG,gBAAgB,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;CAClD,CAAC;AAEvC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,eAAO,MAAM,mBAAmB,EAAE;IAChC,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,WAAW,EAAE,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,MAAM,EAAE,UAAU,KAAK,gBAAgB,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC1F,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,OAAO,CAAC,WAAW,EAAE,CAAC,EAAE,CAAC,CAAC,GAAG,gBAAgB,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;CACjD,CAAC;AAExC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,UAAU,yEAQvD"}
@@ -1,36 +1,182 @@
1
+ import { Fx } from "@typed/fx";
1
2
  import * as Effect from "effect/Effect";
2
- import * as Exit from "effect/Exit";
3
3
  import { dual } from "effect/Function";
4
4
  import * as Layer from "effect/Layer";
5
5
  import * as Option from "effect/Option";
6
- import * as Scope from "effect/Scope";
7
- import * as ServiceMap from "effect/ServiceMap";
8
- import { RouteContext } from "effect/unstable/http/HttpRouter";
6
+ import * as Context from "effect/Context";
7
+ import { hasProperty } from "effect/Predicate";
8
+ import * as Stream from "effect/Stream";
9
+ import { RouteContext, } from "effect/unstable/http/HttpRouter";
9
10
  import * as HttpServerError from "effect/unstable/http/HttpServerError";
10
11
  import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
11
12
  import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
12
- import { RefSubject } from "@typed/fx";
13
- import { compile, CurrentRoute, makeCatchManager, makeLayerManager, makeLayoutManager, Join, Parse, } from "@typed/router";
13
+ import { compile, CurrentRoute, makeRouteExecutor, Join, Parse, } from "@typed/router";
14
+ import { Uuid7State } from "@typed/id/Uuid7";
14
15
  import { initialMemory } from "@typed/navigation";
15
- import { renderToHtmlString } from "@typed/template";
16
- export const ssrForHttp = dual(2, (router, input) => {
17
- return Effect.gen(function* () {
18
- const matcher = Option.match(yield* Effect.serviceOption(CurrentRoute), {
19
- onNone: () => input,
20
- onSome: (parent) => input.prefix(parent.route),
16
+ import { renderToHtml, renderToHtmlString } from "@typed/template";
17
+ const htmlResponseHeaders = { "content-type": "text/html; charset=utf-8" };
18
+ const bufferedResponse = () => Effect.fn((rendered, requestServices) => Effect.gen(function* () {
19
+ const html = yield* renderToHtmlString(rendered).pipe(Effect.provideContext(requestServices));
20
+ return HttpServerResponse.text(html, { headers: htmlResponseHeaders });
21
+ }));
22
+ const streamingResponse = () => Effect.fn((rendered, requestServices) => Effect.gen(function* () {
23
+ const context = Context.merge(requestServices, yield* Effect.context());
24
+ return HttpServerResponse.stream(Stream.provideContext(renderToHtml(rendered).pipe(Fx.provideContext(requestServices), Fx.toStream, Stream.encodeText), context), { headers: htmlResponseHeaders });
25
+ }));
26
+ function makeSsrForHttp(createResponse) {
27
+ return dual(2, (router, input) => {
28
+ return Effect.gen(function* () {
29
+ const matcher = Option.match(yield* Effect.serviceOption(CurrentRoute), {
30
+ onNone: () => input,
31
+ onSome: (parent) => input.prefix(parent.route),
32
+ });
33
+ const entries = compile(matcher.cases);
34
+ const currentServices = yield* Effect.context();
35
+ const candidatesByPath = new Map();
36
+ for (const entry of entries) {
37
+ const path = getMatcherPath(entry.route.path);
38
+ const candidates = candidatesByPath.get(path);
39
+ if (candidates === undefined) {
40
+ candidatesByPath.set(path, [entry]);
41
+ }
42
+ else {
43
+ candidates.push(entry);
44
+ }
45
+ }
46
+ yield* router.addAll(Array.from(candidatesByPath, ([path, candidates]) => toRoute(path, candidates, currentServices, createResponse)));
21
47
  });
22
- const entries = compile(matcher.cases);
23
- const currentServices = yield* Effect.services();
24
- yield* router.addAll(entries.map((e) => toRoute(e, currentServices)));
25
48
  });
26
- });
49
+ }
50
+ function getMatcherPath(path) {
51
+ const queryStart = path.search(/\?[^/?]+=/);
52
+ return (queryStart < 0 ? path : path.slice(0, queryStart));
53
+ }
54
+ /**
55
+ * Registers buffered, GET-only HTML routes for a matcher.
56
+ *
57
+ * Path captures take precedence over same-named query parameters. Rendering
58
+ * completes before the response is created; this adapter does not stream.
59
+ *
60
+ * @remarks
61
+ * ## Why
62
+ *
63
+ * Typed route matchers already describe decoding, guards, parent prefixes, and
64
+ * render output. `ssrForHttp` installs that same model in Effect's HttpRouter
65
+ * and buffers one complete HTML document for runtimes that require a known body.
66
+ *
67
+ * ## Ownership and lifetime
68
+ *
69
+ * Registration adds GET routes to the supplied Effect HttpRouter. Each request
70
+ * creates request-local memory navigation and current-route services; the
71
+ * request Effect owns rendering and all finalizers. Buffered rendering must
72
+ * complete before the response is returned. Rendering HTML does not mount or
73
+ * hydrate a browser client.
74
+ *
75
+ * ## Routing behavior
76
+ *
77
+ * Matcher cases sharing a path are tried in their compiled order. Path params
78
+ * overwrite same-named query params. Decode/guard failures continue candidate
79
+ * selection; a matched renderer failure stays in the typed request error channel.
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * import { ssrForHttp } from "@typed/ui/HttpRouter"
84
+ * import { Effect } from "effect"
85
+ * import * as HttpRouter from "effect/unstable/http/HttpRouter"
86
+ * import { Parse, match } from "@typed/router"
87
+ * import { html } from "@typed/template"
88
+ *
89
+ * const pages = match(Parse("/"), html`<h1>Home</h1>`)
90
+ * const routes = Effect.gen(function* () {
91
+ * const router = yield* HttpRouter.make
92
+ * yield* ssrForHttp(router, pages)
93
+ * return router
94
+ * })
95
+ * ```
96
+ *
97
+ * @since 1.0.0
98
+ * @category Buffered server rendering
99
+ */
100
+ export const ssrForHttp = makeSsrForHttp(bufferedResponse());
101
+ /**
102
+ * Registers streaming, GET-only HTML routes for a matcher.
103
+ *
104
+ * Path captures take precedence over same-named query parameters. HTML chunks
105
+ * are emitted as they are rendered via `renderToHtml` and `HttpServerResponse.stream`.
106
+ *
107
+ * @remarks
108
+ * ## Why
109
+ *
110
+ * Streaming lets the server send ordered renderer-owned HTML chunks before the
111
+ * whole page completes while preserving the same Typed matcher and Effect HTTP model.
112
+ *
113
+ * ## Ownership and lifetime
114
+ *
115
+ * Registration adds GET routes to the supplied router. Each request owns its
116
+ * navigation/router services and stream Scope. Client cancellation interrupts
117
+ * rendering and runs finalizers. The stream carries typed renderer failures;
118
+ * producing HTML alone does not attach client hydration behavior.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * import { streamingSsrForHttp } from "@typed/ui/HttpRouter"
123
+ * import { Effect } from "effect"
124
+ * import * as HttpRouter from "effect/unstable/http/HttpRouter"
125
+ * import { Parse, match } from "@typed/router"
126
+ * import { html } from "@typed/template"
127
+ *
128
+ * const pages = match(Parse("/"), html`<h1>Streamed home</h1>`)
129
+ * const routes = Effect.gen(function* () {
130
+ * const router = yield* HttpRouter.make
131
+ * yield* streamingSsrForHttp(router, pages)
132
+ * return router
133
+ * })
134
+ * ```
135
+ *
136
+ * @since 1.0.0
137
+ * @category Streaming server rendering
138
+ */
139
+ export const streamingSsrForHttp = makeSsrForHttp(streamingResponse());
140
+ /**
141
+ * Converts Effect HTTP server errors into empty 400, 404, or 500 responses.
142
+ * Other failures remain in the global error channel.
143
+ *
144
+ * @remarks
145
+ * ## Why
146
+ *
147
+ * Effect's structured server failures have a safe transport status, whereas
148
+ * application/domain errors must remain visible for deliberate handling.
149
+ *
150
+ * ## Ownership and lifetime
151
+ *
152
+ * The middleware is installed on the supplied router and runs in each request
153
+ * lifetime. It allocates no long-lived resource and does not log, swallow, or
154
+ * reclassify non-HttpServerError failures.
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * import { handleHttpServerError } from "@typed/ui/HttpRouter"
159
+ * import { Effect } from "effect"
160
+ * import * as HttpRouter from "effect/unstable/http/HttpRouter"
161
+ *
162
+ * const router = Effect.gen(function* () {
163
+ * const router = yield* HttpRouter.make
164
+ * yield* handleHttpServerError(router)
165
+ * return router
166
+ * })
167
+ * ```
168
+ *
169
+ * @since 1.0.0
170
+ * @category HTTP error responses
171
+ */
27
172
  export function handleHttpServerError(router) {
28
173
  return router.addGlobalMiddleware(Effect.catch((error) => HttpServerError.isHttpServerError(error)
29
- ? Effect.succeed(HttpServerResponse.text(error.message, { status: getStatus(error) }))
174
+ ? Effect.succeed(HttpServerResponse.empty({ status: getStatus(error) }))
30
175
  : Effect.fail(error)));
31
176
  }
32
177
  function getStatus(error) {
33
- switch (error.reason._tag) {
178
+ const reason = error.reason;
179
+ switch (reason._tag) {
34
180
  case "RouteNotFound":
35
181
  return 404;
36
182
  case "RequestParseError":
@@ -38,20 +184,23 @@ function getStatus(error) {
38
184
  case "InternalError":
39
185
  case "ResponseError":
40
186
  return 500;
187
+ default: {
188
+ const _exhaustive = reason;
189
+ return 500;
190
+ }
41
191
  }
42
192
  }
43
- function toRoute(entry, currentServices) {
193
+ function toRoute(path, candidates, currentServices, createResponse) {
44
194
  return {
45
- "~effect/http/HttpRouter/Route": "~effect/http/HttpRouter/Route",
195
+ ["~effect/http/HttpRouter/Route"]: "~effect/http/HttpRouter/Route",
46
196
  method: "GET",
47
- path: entry.route.path,
197
+ path,
48
198
  handler: Effect.gen(function* () {
49
- const fiberId = yield* Effect.fiberId;
50
- const rootScope = yield* Effect.scope;
51
199
  const routeContext = yield* RouteContext;
52
200
  const request = yield* HttpServerRequest.HttpServerRequest;
53
201
  const searchParams = yield* HttpServerRequest.ParsedSearchParams;
54
- const provided = Layer.mergeAll(initialMemory({ url: request.url }), Layer.succeed(CurrentRoute, yield* Effect.serviceOption(CurrentRoute).pipe(Effect.map(Option.match({
202
+ const requestUrl = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost"));
203
+ const provided = Layer.mergeAll(initialMemory({ url: requestUrl, origin: requestUrl.origin }).pipe(Layer.provide(Uuid7State.Default)), Layer.succeed(CurrentRoute, yield* Effect.serviceOption(CurrentRoute).pipe(Effect.map(Option.match({
55
204
  onNone: () => ({
56
205
  route: Parse(request.url),
57
206
  parent: undefined,
@@ -61,39 +210,33 @@ function toRoute(entry, currentServices) {
61
210
  parent,
62
211
  }),
63
212
  })))));
64
- const input = { ...routeContext.params, ...searchParams };
65
- const params = yield* Effect.mapError(entry.decode(input), (cause) => new HttpServerError.HttpServerError({
66
- reason: new HttpServerError.RequestParseError({ request, cause }),
67
- }));
68
- const memoMap = yield* Layer.makeMemoMap;
69
- const layerManager = makeLayerManager(memoMap, rootScope, fiberId);
70
- const layoutManager = makeLayoutManager(rootScope, fiberId);
71
- const catchManager = makeCatchManager(rootScope, fiberId);
72
- const prepared = yield* layerManager.prepare(entry.layers.concat(provided));
73
- const guardExit = yield* entry
74
- .guard(params)
75
- .pipe(Effect.provideServices(prepared.services), Effect.exit);
76
- if (Exit.isFailure(guardExit) || Option.isNone(guardExit.value)) {
77
- yield* prepared.rollback;
78
- return yield* new HttpServerError.HttpServerError({
79
- reason: new HttpServerError.RouteNotFound({ request }),
80
- });
81
- }
82
- const matchedParams = guardExit.value.value;
83
- yield* prepared.commit;
84
- const scope = yield* Scope.fork(rootScope);
85
- const paramsRef = yield* RefSubject.make(matchedParams).pipe(Scope.provide(scope));
86
- const preparedServices = prepared.services;
87
- const handlerServices = ServiceMap.merge(ServiceMap.merge(currentServices, preparedServices), ServiceMap.make(Scope.Scope, scope));
88
- const handlerFx = entry.handler(paramsRef);
89
- const withLayouts = yield* layoutManager.apply(entry.layouts, matchedParams, handlerFx, preparedServices);
90
- const withCatches = yield* catchManager.apply(entry.catches, withLayouts, preparedServices);
91
- const html = yield* renderToHtmlString(withCatches).pipe(Effect.provideServices(handlerServices));
92
- return HttpServerResponse.text(html, {
93
- headers: { "content-type": "text/html; charset=utf-8" },
94
- });
213
+ const input = { ...searchParams, ...routeContext.params };
214
+ const requestServices = Context.merge(currentServices, yield* Effect.context());
215
+ const executor = yield* makeRouteExecutor().pipe(Effect.provideContext(requestServices));
216
+ const rendered = yield* executor
217
+ .transition({
218
+ path: request.url,
219
+ input,
220
+ candidates,
221
+ layers: [provided],
222
+ })
223
+ .pipe(Effect.catchIf(isRouteDecodeError, (error) => new HttpServerError.HttpServerError({
224
+ reason: new HttpServerError.RequestParseError({ request, cause: error }),
225
+ })), Effect.catchIf(isRouteGuardError, () => new HttpServerError.HttpServerError({
226
+ reason: new HttpServerError.RouteNotFound({ request }),
227
+ })));
228
+ return yield* createResponse(rendered, requestServices);
95
229
  }),
96
230
  uninterruptible: false,
97
- prefix: undefined,
231
+ prefix: Option.none(),
98
232
  };
99
233
  }
234
+ function hasTag(value, tag) {
235
+ return hasProperty(value, "_tag") && value._tag === tag;
236
+ }
237
+ function isRouteDecodeError(value) {
238
+ return hasTag(value, "RouteDecodeError");
239
+ }
240
+ function isRouteGuardError(value) {
241
+ return hasTag(value, "RouteGuardError");
242
+ }