@loadbare/app 0.4.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.
Files changed (171) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +95 -0
  3. package/dist/build/assemble.d.ts +31 -0
  4. package/dist/build/assemble.d.ts.map +1 -0
  5. package/dist/build/assemble.js +53 -0
  6. package/dist/build/cli.d.ts +30 -0
  7. package/dist/build/cli.d.ts.map +1 -0
  8. package/dist/build/cli.js +107 -0
  9. package/dist/build/elements.d.ts +61 -0
  10. package/dist/build/elements.d.ts.map +1 -0
  11. package/dist/build/elements.js +158 -0
  12. package/dist/build/expand.d.ts +45 -0
  13. package/dist/build/expand.d.ts.map +1 -0
  14. package/dist/build/expand.js +386 -0
  15. package/dist/build/format.d.ts +28 -0
  16. package/dist/build/format.d.ts.map +1 -0
  17. package/dist/build/format.js +42 -0
  18. package/dist/build/locations.d.ts +87 -0
  19. package/dist/build/locations.d.ts.map +1 -0
  20. package/dist/build/locations.js +173 -0
  21. package/dist/build/package-root.d.ts +9 -0
  22. package/dist/build/package-root.d.ts.map +1 -0
  23. package/dist/build/package-root.js +24 -0
  24. package/dist/build/pages.d.ts +25 -0
  25. package/dist/build/pages.d.ts.map +1 -0
  26. package/dist/build/pages.js +54 -0
  27. package/dist/build/styles.d.ts +13 -0
  28. package/dist/build/styles.d.ts.map +1 -0
  29. package/dist/build/styles.js +18 -0
  30. package/dist/client.js +522 -0
  31. package/dist/core/lb-constants.d.ts +23 -0
  32. package/dist/core/lb-constants.d.ts.map +1 -0
  33. package/dist/core/lb-constants.js +95 -0
  34. package/dist/core/lb-types.d.ts +88 -0
  35. package/dist/core/lb-types.d.ts.map +1 -0
  36. package/dist/core/lb-types.js +5 -0
  37. package/dist/demo-static/src/widgets/app-box.d.ts +15 -0
  38. package/dist/demo-static/src/widgets/app-box.d.ts.map +1 -0
  39. package/dist/demo-static/src/widgets/app-box.js +19 -0
  40. package/dist/hub/lb-apply.d.ts +13 -0
  41. package/dist/hub/lb-apply.d.ts.map +1 -0
  42. package/dist/hub/lb-apply.js +77 -0
  43. package/dist/hub/lb-hub.d.ts +2 -0
  44. package/dist/hub/lb-hub.d.ts.map +1 -0
  45. package/dist/hub/lb-hub.js +242 -0
  46. package/dist/hub/lb-rows.d.ts +18 -0
  47. package/dist/hub/lb-rows.d.ts.map +1 -0
  48. package/dist/hub/lb-rows.js +106 -0
  49. package/dist/server/lb-express.d.ts +28 -0
  50. package/dist/server/lb-express.d.ts.map +1 -0
  51. package/dist/server/lb-express.js +77 -0
  52. package/dist/server/lb-server.d.ts +174 -0
  53. package/dist/server/lb-server.d.ts.map +1 -0
  54. package/dist/server/lb-server.js +79 -0
  55. package/dist/tests/assemble.test.d.ts +8 -0
  56. package/dist/tests/assemble.test.d.ts.map +1 -0
  57. package/dist/tests/assemble.test.js +51 -0
  58. package/dist/tests/elements.test.d.ts +8 -0
  59. package/dist/tests/elements.test.d.ts.map +1 -0
  60. package/dist/tests/elements.test.js +111 -0
  61. package/dist/tests/expand.test.d.ts +10 -0
  62. package/dist/tests/expand.test.d.ts.map +1 -0
  63. package/dist/tests/expand.test.js +226 -0
  64. package/dist/tests/fixtures/elements/collision/elements.d.ts +5 -0
  65. package/dist/tests/fixtures/elements/collision/elements.d.ts.map +1 -0
  66. package/dist/tests/fixtures/elements/collision/elements.js +3 -0
  67. package/dist/tests/fixtures/elements/collision/widgets/acme-widget.d.ts +2 -0
  68. package/dist/tests/fixtures/elements/collision/widgets/acme-widget.d.ts.map +1 -0
  69. package/dist/tests/fixtures/elements/collision/widgets/acme-widget.js +1 -0
  70. package/dist/tests/fixtures/elements/local/widgets/app-box.d.ts +2 -0
  71. package/dist/tests/fixtures/elements/local/widgets/app-box.d.ts.map +1 -0
  72. package/dist/tests/fixtures/elements/local/widgets/app-box.js +1 -0
  73. package/dist/tests/fixtures/elements/manifest/elements.d.ts +5 -0
  74. package/dist/tests/fixtures/elements/manifest/elements.d.ts.map +1 -0
  75. package/dist/tests/fixtures/elements/manifest/elements.js +3 -0
  76. package/dist/tests/fixtures/elements/manifest-bad-tag/elements.d.ts +5 -0
  77. package/dist/tests/fixtures/elements/manifest-bad-tag/elements.d.ts.map +1 -0
  78. package/dist/tests/fixtures/elements/manifest-bad-tag/elements.js +3 -0
  79. package/dist/tests/fixtures/elements/manifest-bad-value/elements.d.ts +5 -0
  80. package/dist/tests/fixtures/elements/manifest-bad-value/elements.d.ts.map +1 -0
  81. package/dist/tests/fixtures/elements/manifest-bad-value/elements.js +3 -0
  82. package/dist/tests/golden.test.d.ts +19 -0
  83. package/dist/tests/golden.test.d.ts.map +1 -0
  84. package/dist/tests/golden.test.js +60 -0
  85. package/dist/tests/helpers/console.d.ts +20 -0
  86. package/dist/tests/helpers/console.d.ts.map +1 -0
  87. package/dist/tests/helpers/console.js +28 -0
  88. package/dist/tests/helpers/dom.d.ts +18 -0
  89. package/dist/tests/helpers/dom.d.ts.map +1 -0
  90. package/dist/tests/helpers/dom.js +22 -0
  91. package/dist/tests/helpers/window.d.ts +43 -0
  92. package/dist/tests/helpers/window.d.ts.map +1 -0
  93. package/dist/tests/helpers/window.js +78 -0
  94. package/dist/tests/lb-apply.test.d.ts +8 -0
  95. package/dist/tests/lb-apply.test.d.ts.map +1 -0
  96. package/dist/tests/lb-apply.test.js +153 -0
  97. package/dist/tests/lb-express.test.d.ts +14 -0
  98. package/dist/tests/lb-express.test.d.ts.map +1 -0
  99. package/dist/tests/lb-express.test.js +238 -0
  100. package/dist/tests/lb-input.test.d.ts +9 -0
  101. package/dist/tests/lb-input.test.d.ts.map +1 -0
  102. package/dist/tests/lb-input.test.js +78 -0
  103. package/dist/tests/lb-list.test.d.ts +12 -0
  104. package/dist/tests/lb-list.test.d.ts.map +1 -0
  105. package/dist/tests/lb-list.test.js +44 -0
  106. package/dist/tests/lb-options.test.d.ts +10 -0
  107. package/dist/tests/lb-options.test.d.ts.map +1 -0
  108. package/dist/tests/lb-options.test.js +121 -0
  109. package/dist/tests/lb-picker.test.d.ts +14 -0
  110. package/dist/tests/lb-picker.test.d.ts.map +1 -0
  111. package/dist/tests/lb-picker.test.js +59 -0
  112. package/dist/tests/lb-rows.test.d.ts +12 -0
  113. package/dist/tests/lb-rows.test.d.ts.map +1 -0
  114. package/dist/tests/lb-rows.test.js +336 -0
  115. package/dist/tests/lb-select.test.d.ts +9 -0
  116. package/dist/tests/lb-select.test.d.ts.map +1 -0
  117. package/dist/tests/lb-select.test.js +71 -0
  118. package/dist/tests/lb-server.test.d.ts +9 -0
  119. package/dist/tests/lb-server.test.d.ts.map +1 -0
  120. package/dist/tests/lb-server.test.js +495 -0
  121. package/dist/tests/lb-table.test.d.ts +15 -0
  122. package/dist/tests/lb-table.test.d.ts.map +1 -0
  123. package/dist/tests/lb-table.test.js +205 -0
  124. package/dist/tests/pages.test.d.ts +6 -0
  125. package/dist/tests/pages.test.d.ts.map +1 -0
  126. package/dist/tests/pages.test.js +98 -0
  127. package/dist/tests/styles.test.d.ts +7 -0
  128. package/dist/tests/styles.test.d.ts.map +1 -0
  129. package/dist/tests/styles.test.js +73 -0
  130. package/dist/widgets/index.d.ts +7 -0
  131. package/dist/widgets/index.d.ts.map +1 -0
  132. package/dist/widgets/index.js +6 -0
  133. package/dist/widgets/lb-input.d.ts +2 -0
  134. package/dist/widgets/lb-input.d.ts.map +1 -0
  135. package/dist/widgets/lb-input.js +48 -0
  136. package/dist/widgets/lb-list.d.ts +2 -0
  137. package/dist/widgets/lb-list.d.ts.map +1 -0
  138. package/dist/widgets/lb-list.js +17 -0
  139. package/dist/widgets/lb-options.d.ts +26 -0
  140. package/dist/widgets/lb-options.d.ts.map +1 -0
  141. package/dist/widgets/lb-options.js +72 -0
  142. package/dist/widgets/lb-picker.d.ts +2 -0
  143. package/dist/widgets/lb-picker.d.ts.map +1 -0
  144. package/dist/widgets/lb-picker.js +25 -0
  145. package/dist/widgets/lb-select.d.ts +2 -0
  146. package/dist/widgets/lb-select.d.ts.map +1 -0
  147. package/dist/widgets/lb-select.js +43 -0
  148. package/dist/widgets/lb-table.d.ts +2 -0
  149. package/dist/widgets/lb-table.d.ts.map +1 -0
  150. package/dist/widgets/lb-table.js +113 -0
  151. package/docs/application-chrome.md +36 -0
  152. package/docs/building-html-pages.md +130 -0
  153. package/docs/getting-started.md +120 -0
  154. package/docs/guide.md +1164 -0
  155. package/docs/hosting.md +218 -0
  156. package/docs/latent-risks.md +20 -0
  157. package/docs/theory.md +226 -0
  158. package/package.json +85 -0
  159. package/widgets/index.ts +6 -0
  160. package/widgets/lb-input.html +1 -0
  161. package/widgets/lb-input.ts +64 -0
  162. package/widgets/lb-list.html +1 -0
  163. package/widgets/lb-list.ts +21 -0
  164. package/widgets/lb-options.html +4 -0
  165. package/widgets/lb-options.ts +88 -0
  166. package/widgets/lb-picker.html +7 -0
  167. package/widgets/lb-picker.ts +27 -0
  168. package/widgets/lb-select.html +4 -0
  169. package/widgets/lb-select.ts +55 -0
  170. package/widgets/lb-table.html +8 -0
  171. package/widgets/lb-table.ts +126 -0
@@ -0,0 +1,218 @@
1
+ # Hosting a Loadbare App Application
2
+
3
+ Everything here is written once for an application and then left alone. None
4
+ of it is per-page knowledge — for that, see the [Programmer's
5
+ Guide](./guide.md), which builds a page and stops at the point where the page
6
+ has to be served.
7
+
8
+ Loadbare App ships no HTTP server, no router and no data layer. The engine in
9
+ `@loadbare/app/server` imports one thing — its own wire types — and never
10
+ sees a request object. So hosting is an adapter you write once, and this
11
+ document is that adapter.
12
+
13
+ Status: runs, except where marked.
14
+
15
+ ---
16
+
17
+ ## Register the page
18
+
19
+ Status: partial. Nothing loads `pages/<name>.queries.ts` or
20
+ `pages/<name>.hooks.ts` by name yet, so an application names its pages in a
21
+ registry. `demo/pages.ts` is the demo's:
22
+
23
+ ```ts
24
+ import { createHub } from "@loadbare/app/server";
25
+
26
+ export const hub = createHub({
27
+ hello: { queries: helloQueries, hooks: helloHooks },
28
+ });
29
+ ```
30
+
31
+ `createHub` returns two functions, `dataForPage` and `runAction`. The pages
32
+ are fixed at startup; the context is not, and arrives with each call.
33
+
34
+ When the loader ships, this file disappears and the directory is the registry.
35
+
36
+ ## The shell
37
+
38
+ The shell is the one document the server sends for every route and every user.
39
+ It carries the chrome — whatever surrounds the page, such as a header and a
40
+ nav — an empty `<main>`, and every page host in the application, each wrapped
41
+ in a `<template>`:
42
+
43
+ ```html
44
+ <lb-hub>
45
+ <header><h1>Loadbare App Demo</h1></header>
46
+ <nav>
47
+ <a href="/hello" lb-nav-link>Hello</a>
48
+ <a href="/counter" lb-nav-link>Counter</a>
49
+ </nav>
50
+ <main></main>
51
+ </lb-hub>
52
+ <template id="page-hello">…</template>
53
+ <template id="page-counter">…</template>
54
+ ```
55
+
56
+ `<lb-hub>` is the hub: a single custom element that sits above every page,
57
+ outside `<main>`. It survives navigation, so it needs no id. It receives every
58
+ request a widget sends, POSTs it, and applies the response.
59
+
60
+ Because the shell carries no data, it is identical for every user and every
61
+ route, which is what makes it cacheable indefinitely. Values arrive later, on
62
+ the data channel, and land on the tree as attributes.
63
+
64
+ Assembling it is a build step: read each page host, expand its widgets, wrap
65
+ each in `<template id="page-<name>">`, and concatenate. `demo/shell.ts` does
66
+ this at startup rather than at build time, which is a convenience of the demo
67
+ and not the design.
68
+
69
+ ## Navigation
70
+
71
+ Every page host already ships in the document, so navigation moves markup that
72
+ is already there. Mark an anchor to have the hub intercept it:
73
+
74
+ ```html
75
+ <a href="/entity" lb-nav-link>Entity</a>
76
+ ```
77
+
78
+ The hub calls `history.pushState`, selects the host with
79
+ `getElementById("page-" + name)`, fetches that page's data, then inserts and
80
+ hydrates in one synchronous block. The browser does not paint mid-task, so
81
+ there is no empty flash.
82
+
83
+ Anchors without the attribute are left alone. There is no fetch on the host
84
+ channel, no route table, and no in-flight state.
85
+
86
+ The path-to-page rule is one segment. An empty path currently resolves to
87
+ `counter`, which is demo leakage in `hub/lb-hub.ts` and should be
88
+ configuration.
89
+
90
+ ---
91
+
92
+ ## Serving it with Express
93
+
94
+ The demo runs on Express, because Loadbare App has no opinion about which server you
95
+ bring and the community has a settled answer. The demo splits the work along
96
+ the line that matters:
97
+
98
+ | file | whose it is |
99
+ | ---------------------- | ----------------------------------------------- |
100
+ | `demo/lb-routes.ts` | Loadbare App's. Every application writes this same file. |
101
+ | `demo/server.ts` | the demo's. Its store, its bundle, its shell. |
102
+
103
+ ### Two endpoints, and no third
104
+
105
+ ```
106
+ GET /lb/data?page=<name> the page's whole query set
107
+ POST /lb?page=<name> one operation, then its refresh set
108
+ ```
109
+
110
+ Both names are constants — `LB_DATA_ENDPOINT` and
111
+ `LB_REQUEST_ENDPOINT`. The page rides on the query string rather than in
112
+ the body, which is what lets the operation set stay closed.
113
+
114
+ They mount as an ordinary router:
115
+
116
+ ```ts
117
+ export function hubRoutes(hub: Hub, contextFor: ContextFor): Router {
118
+ const router = express.Router();
119
+
120
+ // Scoped here, not on the app: the routes that need a parsed body are the
121
+ // routes that say so.
122
+ router.use(express.json());
123
+
124
+ router.get(LB_DATA_ENDPOINT, async (req, res) => {
125
+ const page = String(req.query.page ?? "");
126
+ res.json(await hub.dataForPage(page, contextFor(req)));
127
+ });
128
+
129
+ router.post(LB_REQUEST_ENDPOINT, async (req, res) => {
130
+ const { name, query, key, cell, value } = req.body as HubRequest;
131
+ const page = String(req.query.page ?? "");
132
+ res.json(
133
+ await hub.runAction(
134
+ page,
135
+ name,
136
+ { query, key, cell, value },
137
+ contextFor(req),
138
+ ),
139
+ );
140
+ });
141
+
142
+ return router;
143
+ }
144
+ ```
145
+
146
+ Note what the POST handler does not do. It does not look up a function by a
147
+ name from the wire, and it does not read a payload. It passes a declared name
148
+ and the addressing coordinates to `runAction`, which refuses any name the page
149
+ did not declare.
150
+
151
+ ### The context is injected, because it is yours
152
+
153
+ `contextFor` is a parameter rather than something the router builds, because
154
+ building it is the one part of this that is genuinely the application's:
155
+
156
+ ```ts
157
+ export type ContextFor = (req: Request) => HubContext;
158
+ ```
159
+
160
+ The demo's opens the same store every time. A real one opens it for the
161
+ authenticated caller — which is the whole reason the engine takes a context
162
+ per request rather than holding one for the life of the process:
163
+
164
+ ```ts
165
+ function contextFor(req: Request): HubContext {
166
+ return { db: openDb(req.user) };
167
+ }
168
+ ```
169
+
170
+ ### Ordering is yours, and Loadbare App asks for one thing
171
+
172
+ Express keeps one ordered list of middleware and walks it top to bottom.
173
+ Nothing about `app.use` makes authentication run first; it runs first because
174
+ you registered it first. Loadbare App's only requirement is the obvious one: the
175
+ context must be built per request, after whatever establishes identity, and
176
+ before a Loadbare App route runs.
177
+
178
+ ```ts
179
+ const app = express();
180
+
181
+ app.use(session(...));
182
+ app.use(authenticate); // sets req.user, or 401s
183
+ app.use(hubRoutes(hub, contextFor)); // now req.user exists
184
+
185
+ app.get("/client.js", serveBundle);
186
+ app.get(/.*/, serveShell); // last, or it swallows everything
187
+ app.use(errors); // four arguments, last of all
188
+ ```
189
+
190
+ You can scope instead of sequence — `app.use("/lb", authenticate)` covers
191
+ both endpoints, since `use` matches on prefix and they share one. Either way
192
+ the decision is yours; Loadbare App never sees the request.
193
+
194
+ ### The demo's own routes
195
+
196
+ Everything below the Loadbare App mount is this application's, and would differ in
197
+ yours:
198
+
199
+ ```ts
200
+ app.get(/.*/, async (_req, res) => {
201
+ res.type("html").send(await buildShell());
202
+ });
203
+ ```
204
+
205
+ Every remaining route gets the same shell. The demo rebuilds it per request
206
+ only so that editing a page host shows up on reload; it is a build step
207
+ wearing a route's clothes.
208
+
209
+ ### Three things that will bite you, none of them Loadbare App's
210
+
211
+ - **The catch-all must be last.** It is a route like any other, and Express
212
+ takes the first match. Put it above the Loadbare App mount and it answers
213
+ `/lb/data` with HTML.
214
+ - **Express 5 changed the wildcard.** A bare `"*"` is no longer a valid path;
215
+ use a regex or `"/*splat"`.
216
+ - **Express 4 does not catch async rejections.** An `await` that throws inside
217
+ a handler hangs the request rather than reaching your error middleware.
218
+ Express 5 forwards it. The demo is on 5 and has no `try`/`catch` anywhere.
@@ -0,0 +1,20 @@
1
+ # Latent Risks
2
+
3
+ Issues that a single developer iterating on one app is unlikely to hit by
4
+ accident, but that are expensive to retrofit once real usage exposes them.
5
+ Not being built speculatively — read this when the symptom below actually
6
+ shows up, as a prompt to come back and decide the shape deliberately.
7
+
8
+ ## Staleness and concurrent writers
9
+
10
+ Two tabs, or two users, updating the same projection at once. A solo
11
+ developer testing in one browser will not produce this by accident, and
12
+ retrofitting a version or conflict check onto every tuple after the fact
13
+ touches every widget that writes.
14
+
15
+ ## Nesting
16
+
17
+ Whether a tuple may contain a projection (master-detail, an expanding row).
18
+ `theory.md` already flags this as possibly load-bearing if disallowed. Worth
19
+ a decision-in-principle the first time a master-detail page is built, even
20
+ before the mechanism is needed elsewhere.
package/docs/theory.md ADDED
@@ -0,0 +1,226 @@
1
+ # Theory of Loadbare App
2
+
3
+ Loadbare App is a web framework that answers the author's pain points with modern
4
+ web development: a paradoxical situation in which developer ergonomics appear
5
+ to dominate framework architecture but we end up with developer tools that
6
+ inflict increasing pain and expense at scale. User
7
+ experience, especially speed, is left as "an exercise for the reader" or waved
8
+ away as irrelevant due to "powerful modern hardware."
9
+
10
+ The result is that it is incredibly expensive to make a web app, and the result
11
+ is usually very slow.
12
+
13
+ The iterative effort began with Axiom Zero: The best
14
+ developer experience is creating an application that users appreciate.
15
+ A framework for application development must put user needs first, and
16
+ craft the resulting solution patterns to developer needs after the best
17
+ user result is identified and achieved.
18
+
19
+ So we begin with user experience, a fancy way of saying, "why we built this
20
+ darn app in the first place, and what makes people likely to come back."
21
+ Modern expectations for a web app are numerous and span multiple domains. The
22
+ one domain that seems to have been forgotten as inconvenient to --toy-making--
23
+ developer tool forging is throughput, or performance. Loadbare App therefore puts
24
+ performance first, every decision must result in a performant application.
25
+
26
+ This leads to Axiom 1: Performance drives all architecture. Primary
27
+ decisions drive performance, secondary decisions do not subvert it.
28
+
29
+ ## The Performance Budget
30
+
31
+ Research over the past 50 years concludes that users appreciate a tight average
32
+ of ~300ms response time from a request to a completed paint.
33
+
34
+ As the round trip is the critical path, we look at a round trip and break down
35
+ the budget into wire
36
+ time, browser time, and server time. Wire time is limited by the speed of light,
37
+ so we pick 200ms as a strong median for a browser and server located in roughly
38
+ the same region.
39
+
40
+ With 200ms consumed on the wire, that leaves 100ms for the server and the browser.
41
+ Within that 100ms, there is browser paint, which we control, and request handling,
42
+ which the framework can structure, and then database response time. Since the
43
+ database is the one thing outside of our control, we come to a simple conclusion:
44
+
45
+ > Global Requirement 1: The framework must consume as little of the
46
+ > 100ms as possible, leaving
47
+ > as much of the 100ms budget as possible to the database.
48
+
49
+
50
+ ## The Split
51
+
52
+ Since the author knew the fat browser frameworks could never be twisted into
53
+ shape to satisfy the ground requirements, he experimented mostly with htmx, and
54
+ investigated dataStar, Turbo, and others.
55
+
56
+ What became clear is that all modern offerings have two things in common:
57
+
58
+ COMMEN ELEMENT 1: They all assume that any DOM node can be mutated, added, or
59
+ removed at any time. Most of the run-time expense and framework complexity
60
+ is a direct result of this assumption.
61
+
62
+ COMMON ELEMENT 2: Their templating systems mix the shape of a response (the
63
+ HTML/CSS and some JS for behavior), with the content of the response (the stuff
64
+ we got from a data store, search engine, relational database or what have you).
65
+
66
+ > *Note: Phoenix LiveView is the only exception I found, it was rejected
67
+ > for reasons explained in [prior-art.md](../docs-llm-slop/prior-art.md).
68
+
69
+ The final iteration, which led to Release 1.0, only got off the ground
70
+ once the fundamental split was identified:
71
+
72
+ > The UI is shipped as a static and permanent "host" for data that is supplied
73
+ > on a separate channel. Page navigation swaps in the page "host" code, and
74
+ > user interaction refreshes data that the framework must push into the DOM.
75
+
76
+ This split does not preclude conditional rendering or list processing, but it
77
+ significantly changes their shape. There are plentiful examples in the
78
+ [guide.md](./guide.md).
79
+
80
+ ## Some Practical Wisdom
81
+
82
+ Given a collection of ideals, a tight budget, and one lonely architectural
83
+ decision, a few more ideas were needed to maintain alignment with Axiom zero,
84
+ that user experience and developer ergonomics must be aligned.
85
+
86
+ 0. Use the fewest mechanisms that are most expressive
87
+ 1. Leverage the browser, don't fight it or supersede it.
88
+ 2. Stick with HTML, don't invent a templating system
89
+ 3. Ship example CSS, but don't force our solution path
90
+ 4. Find a sane default for shipping Javascript
91
+ 5. Use community solutions where they exist (eg, Express),
92
+ don't reinvent a building block one exists that is not going
93
+ to chew too much of our 100ms budget
94
+ 6. The build is the developer-equivalent "speed trumps everything", the
95
+ fastest build has the fewest steps, and the easiest build has the
96
+ least configurations
97
+ 7. Prefer explicit over magic, except in the build, we do not want
98
+ the build to force boilerplate into the code.
99
+ 8. Authentication and Authorization should be demonstrable but are
100
+ squarely outside of scope.
101
+ 9. Keep architectural elements loosely coupled, but don't over-force
102
+ that to the detriment of a coherent solution that can be used
103
+ as-is.
104
+ 10. For 1.0, create a minimal working solution whose elements can
105
+ be extended without fundamental refactoring.
106
+
107
+ ## Implementation Decisions
108
+
109
+ Custom Elements with Light DOM. Loadbare App connects javascript to HTML in
110
+ the simplest way possible. Custom elements and light DOM
111
+ leverage the browser instead of fighting it, and they naturally fit
112
+ the role of code in a tree, which is to manage itself and its descendants.
113
+
114
+ A browser "hub" with two jobs. Job 1 is to respond to navigation requests
115
+ by swapping in the HTML "host" for the page. Job 2 is to catch events,
116
+ send requests to the server, and update host elements that display results.
117
+
118
+ Provide a fixed set of data shapes, from cell (scalar), to a tuple (a
119
+ single collection of cells), or a projection, a collection of tuples.
120
+
121
+ Have the hub locate nodes to update with attributes like `lb-query`,
122
+ `lb-key` and `lb-cell`. Supplement the vocabulary with special ops
123
+ for row deletions or adds, and both CRUD and non-CRUD requests to the
124
+ server.
125
+
126
+ A page system that organizes the HTML host template and the server-side
127
+ code with minimal or zero boilerplate.
128
+
129
+
130
+
131
+ ## Settled Implementation Approaches
132
+
133
+ ### Widgets
134
+
135
+ A widget is an HTML custom element without Shadow DOM. Widgets compose,
136
+ up to the level of a page.
137
+
138
+ ### Defining a Page
139
+
140
+ A "page" is the static host of the HTML main landmark. It can be written into
141
+ a plain old HTML document. How it is packaged, delivered and cached is settled
142
+ in [bundling.md](../docs-llm-slop/bundling.md).
143
+
144
+ We assume a page-level server-side state system.
145
+
146
+ A page also has a set of declared queries in a data structure, mapped to
147
+ the server-side state variables they require. A page GET must execute
148
+ all queries and deliver all results.
149
+
150
+ A page is a set of files sharing a basename: the host, the queries it
151
+ declares, and the hooks it declares. The name of the file says which page
152
+ it belongs to, so nothing inside it has to.
153
+
154
+ User interactions break down into three groups:
155
+
156
+ - Fire-and-forget. The user changed sort order on an HTML TABLE that
157
+ is smart enough to sort its own rows. It still fires a fire-and-forget
158
+ update to server state, so that user choice can be fetched on the next
159
+ page load
160
+ - CRUD operations with hooks, declared per page: the table, the operation,
161
+ whatever custom code it runs, and the queries it refreshes.
162
+ - Declared actions, for work that is not CRUD. The page names what it can be
163
+ asked to do and what each one refreshes. The browser sends a declared name
164
+ and nothing else, so the server decides every value.
165
+
166
+ A hook always states which queries it refreshes. There is no automatic
167
+ mapping from an operation to the projections it affects. The exception is a
168
+ hook that runs before a page GET, which needs no such declaration because
169
+ the whole query set runs after it.
170
+
171
+ ### Data Updates
172
+
173
+ A data update can be a full hydration or rehydration of page, or a refresh of
174
+ some elements based on a user action. In all cases, the following statements hold.
175
+
176
+ We assume the host HTML is fully loaded and upgraded with JS.
177
+
178
+ We define an "addressable" widget as one with custom attributes that allow the
179
+ hub to identify which widgets are responsible for the data fragments it has
180
+ received. A widget is addressed by three coordinates: the declared query, the
181
+ tuple key, and the cell.
182
+
183
+ A cell lands as text when it addresses a native element and as an attribute
184
+ when it addresses a widget. A native element has no behavior of its own, so
185
+ the value is simply its text; a widget owns whatever control it wraps, so it
186
+ receives the value and renders it. The test is the browser's own rule for what
187
+ a custom element is, so the hub holds no knowledge of any particular element.
188
+
189
+ Projections land by method call, and tuples get no mechanism of their own.
190
+
191
+ Because the browser runs `attributeChangedCallback` for attributes already
192
+ present when a widget upgrades, hydration and refresh are the same operation,
193
+ and hydration timing is invisible to widget code.
194
+
195
+ The attribute vocabulary and the binding mechanism are specified in
196
+ [data-binding.md](../docs-llm-slop/data-binding.md).
197
+
198
+ ## Open Implementation Topics
199
+
200
+ Binding, addressing and the update mechanism are settled in
201
+ [data-binding.md](../docs-llm-slop/data-binding.md). The request vocabulary is settled in
202
+ [requests.md](../docs-llm-slop/requests.md). Packaging of the static half is settled in
203
+ [bundling.md](../docs-llm-slop/bundling.md). Expansion of authored markup is settled in
204
+ [expansion.md](../docs-llm-slop/expansion.md). What remains:
205
+
206
+ topics to expand:
207
+
208
+ - values bound to executable attributes. Markup injection is closed:
209
+ values are applied through the DOM and never parsed. An attribute that
210
+ is executable in its own right — href, src, style, on\* — is not, and
211
+ the first widget that binds a URL has to answer for it.
212
+ - navigation replaces a static host rather than data, the one operation
213
+ that does replace host DOM. What makes something a new host versus new
214
+ data for an existing host is the daily modeling decision.
215
+ - nesting. Whether a tuple may contain a projection (master-detail, an
216
+ expanding row). If it may not, that limit is load bearing.
217
+ - staleness and concurrent writers. Two tabs or two users against one
218
+ projection. Do tuples carry a version, or is last-write-wins the
219
+ stated position?
220
+ - validation placement. Per-keystroke feedback cannot afford a round
221
+ trip, so the budget forces some validation into the widget while the
222
+ server remains the source of truth.
223
+ - the declarative path for setting a page-state variable to a literal. A
224
+ native element can send a declared action, because an action carries
225
+ nothing; a control that writes a literal directly has no markup for it
226
+ yet.
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "@loadbare/app",
3
+ "description": "High performance web app framework for server-bound applications",
4
+ "version": "0.4.0",
5
+ "type": "module",
6
+ "files": [
7
+ "dist",
8
+ "widgets",
9
+ "docs",
10
+ "README.md"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "bin": {
16
+ "loadbare-app-build": "dist/build/cli.js"
17
+ },
18
+ "exports": {
19
+ ".": "./dist/hub/lb-hub.js",
20
+ "./constants": "./dist/core/lb-constants.js",
21
+ "./types": "./dist/core/lb-types.js",
22
+ "./rows": "./dist/hub/lb-rows.js",
23
+ "./server": "./dist/server/lb-server.js",
24
+ "./express": "./dist/server/lb-express.js",
25
+ "./build": "./dist/build/elements.js",
26
+ "./widgets/*": "./dist/widgets/*.js"
27
+ },
28
+ "scripts": {
29
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build",
30
+ "preflight": "node scripts/preflight-release.mjs",
31
+ "release": "npm run preflight && npm publish",
32
+ "release:patch": "node scripts/bump-release.mjs patch",
33
+ "release:minor": "node scripts/bump-release.mjs minor",
34
+ "release:major": "node scripts/bump-release.mjs major",
35
+ "prebuild": "node --eval \"fs.rmSync('dist',{recursive:true,force:true})\" --input-type=module",
36
+ "build:client": "esbuild client/main.ts --bundle --format=iife --outfile=dist/client.js",
37
+ "build:server": "tsc --project tsconfig.build.json",
38
+ "build": "npm run build:server && npm run build:client",
39
+ "build:demo": "npm run build:server && tsx build/cli.ts --src demo --out dist/demo",
40
+ "dev": "npm run build:demo && (tsx build/cli.ts --src demo --out dist/demo --watch & tsx demo/server.ts)",
41
+ "build:demo-static": "npm run build:server && tsx build/cli.ts --src demo-static/src --out dist/demo-static",
42
+ "dev:demo-static": "npm run build:demo-static -- --watch & node demo-static/serve.mjs",
43
+ "test": "tsx --test \"tests/**/*.test.ts\"",
44
+ "test:golden": "UPDATE_GOLDEN=1 tsx --test tests/golden.test.ts",
45
+ "pretypecheck": "npm run build:demo",
46
+ "typecheck": "tsc --noEmit",
47
+ "format": "prettier --write .",
48
+ "format:check": "prettier --check ."
49
+ },
50
+ "author": "Ken Downs",
51
+ "license": "Apache-2.0",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://gitlab.com/kendowns/loadbare.git",
55
+ "directory": "packages/app"
56
+ },
57
+ "homepage": "https://gitlab.com/kendowns/loadbare",
58
+ "bugs": {
59
+ "url": "https://gitlab.com/kendowns/loadbare/-/issues"
60
+ },
61
+ "engines": {
62
+ "node": ">=22"
63
+ },
64
+ "dependencies": {
65
+ "esbuild": "^0.28.1",
66
+ "jsdom": "^25.0.0"
67
+ },
68
+ "peerDependencies": {
69
+ "express": "^5.0.0"
70
+ },
71
+ "peerDependenciesMeta": {
72
+ "express": {
73
+ "optional": true
74
+ }
75
+ },
76
+ "devDependencies": {
77
+ "@types/express": "^5.0.6",
78
+ "@types/jsdom": "^21.1.7",
79
+ "@types/node": "^22.0.0",
80
+ "express": "^5.2.1",
81
+ "prettier": "^3.8.4",
82
+ "tsx": "^4.19.0",
83
+ "typescript": "^5.6.0"
84
+ }
85
+ }
@@ -0,0 +1,6 @@
1
+ import "./lb-input";
2
+ import "./lb-select";
3
+ import "./lb-list";
4
+ import "./lb-options";
5
+ import "./lb-table";
6
+ import "./lb-picker";
@@ -0,0 +1 @@
1
+ <label>{{label}} <input readonly="{{readonly}}" /></label>
@@ -0,0 +1,64 @@
1
+ /// <reference lib="dom" />
2
+
3
+ import {
4
+ ATTR_CELL,
5
+ ATTR_KEY,
6
+ ATTR_QUERY,
7
+ ATTR_VALUE,
8
+ LB_EVENT_NAME,
9
+ } from "../core/lb-constants";
10
+ import type { HubRequest } from "../core/lb-types";
11
+
12
+ /**
13
+ * A leaf cell wrapping an <input>. The hub writes one attribute name for
14
+ * every widget type, so the value lands in `lb-value` and the widget
15
+ * forwards it to the control it owns.
16
+ *
17
+ * The other direction of the same cell: on change it sends `cell-change`,
18
+ * addressed by the same lb-query/lb-key/lb-cell coordinates the value
19
+ * arrived on. A `readonly` input never fires `change` from user input, so a
20
+ * readonly cell sends nothing on its own.
21
+ */
22
+ class LbInput extends HTMLElement {
23
+ static observedAttributes = [ATTR_VALUE];
24
+
25
+ attributeChangedCallback(_name: string, _old: string, value: string) {
26
+ const input = this.querySelector("input");
27
+ if (!input) {
28
+ console.error(`lb-input: no <input> to receive the value`);
29
+ return;
30
+ }
31
+ input.value = value;
32
+ }
33
+
34
+ connectedCallback() {
35
+ this.addEventListener("change", () => {
36
+ const input = this.querySelector("input");
37
+ if (!input) {
38
+ console.error(`lb-input: change with no <input>, ignoring`);
39
+ return;
40
+ }
41
+ const query = this.closest(`[${ATTR_QUERY}]`)?.getAttribute(ATTR_QUERY);
42
+ const key = this.closest(`[${ATTR_KEY}]`)?.getAttribute(ATTR_KEY);
43
+ const cell = this.getAttribute(ATTR_CELL);
44
+ if (!query || !key || !cell) {
45
+ console.error(
46
+ `lb-input: change with no query/key/cell coordinates, ignoring`,
47
+ );
48
+ return;
49
+ }
50
+ const detail: HubRequest = {
51
+ op: "cell-change",
52
+ query,
53
+ key,
54
+ cell,
55
+ value: input.value,
56
+ };
57
+ this.dispatchEvent(
58
+ new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }),
59
+ );
60
+ });
61
+ }
62
+ }
63
+
64
+ customElements.define("lb-input", LbInput);
@@ -0,0 +1 @@
1
+ <div lb-slot></div>
@@ -0,0 +1,21 @@
1
+ /// <reference lib="dom" />
2
+
3
+ import type { Projection, HubRowHost } from "../core/lb-types";
4
+ import { applyRows } from "../hub/lb-rows";
5
+
6
+ /**
7
+ * The plain repeater: whatever the page author wrote inside is repeated once
8
+ * per tuple, in the order the server sent.
9
+ *
10
+ * It exists because a projection has to land on a widget, and because a
11
+ * `<table>` cannot be one — a custom element written inside `<tbody>` is
12
+ * discarded by the parser, so the widget goes around the table and the
13
+ * template goes inside it, where the content model already allows it.
14
+ */
15
+ class LbList extends HTMLElement implements HubRowHost {
16
+ acceptRows(result: Projection) {
17
+ applyRows(this, result);
18
+ }
19
+ }
20
+
21
+ customElements.define("lb-list", LbList);
@@ -0,0 +1,4 @@
1
+ <label
2
+ >{{label}}
3
+ <select lb-slot></select
4
+ ></label>