@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.
- package/LICENSE +201 -0
- package/README.md +95 -0
- package/dist/build/assemble.d.ts +31 -0
- package/dist/build/assemble.d.ts.map +1 -0
- package/dist/build/assemble.js +53 -0
- package/dist/build/cli.d.ts +30 -0
- package/dist/build/cli.d.ts.map +1 -0
- package/dist/build/cli.js +107 -0
- package/dist/build/elements.d.ts +61 -0
- package/dist/build/elements.d.ts.map +1 -0
- package/dist/build/elements.js +158 -0
- package/dist/build/expand.d.ts +45 -0
- package/dist/build/expand.d.ts.map +1 -0
- package/dist/build/expand.js +386 -0
- package/dist/build/format.d.ts +28 -0
- package/dist/build/format.d.ts.map +1 -0
- package/dist/build/format.js +42 -0
- package/dist/build/locations.d.ts +87 -0
- package/dist/build/locations.d.ts.map +1 -0
- package/dist/build/locations.js +173 -0
- package/dist/build/package-root.d.ts +9 -0
- package/dist/build/package-root.d.ts.map +1 -0
- package/dist/build/package-root.js +24 -0
- package/dist/build/pages.d.ts +25 -0
- package/dist/build/pages.d.ts.map +1 -0
- package/dist/build/pages.js +54 -0
- package/dist/build/styles.d.ts +13 -0
- package/dist/build/styles.d.ts.map +1 -0
- package/dist/build/styles.js +18 -0
- package/dist/client.js +522 -0
- package/dist/core/lb-constants.d.ts +23 -0
- package/dist/core/lb-constants.d.ts.map +1 -0
- package/dist/core/lb-constants.js +95 -0
- package/dist/core/lb-types.d.ts +88 -0
- package/dist/core/lb-types.d.ts.map +1 -0
- package/dist/core/lb-types.js +5 -0
- package/dist/demo-static/src/widgets/app-box.d.ts +15 -0
- package/dist/demo-static/src/widgets/app-box.d.ts.map +1 -0
- package/dist/demo-static/src/widgets/app-box.js +19 -0
- package/dist/hub/lb-apply.d.ts +13 -0
- package/dist/hub/lb-apply.d.ts.map +1 -0
- package/dist/hub/lb-apply.js +77 -0
- package/dist/hub/lb-hub.d.ts +2 -0
- package/dist/hub/lb-hub.d.ts.map +1 -0
- package/dist/hub/lb-hub.js +242 -0
- package/dist/hub/lb-rows.d.ts +18 -0
- package/dist/hub/lb-rows.d.ts.map +1 -0
- package/dist/hub/lb-rows.js +106 -0
- package/dist/server/lb-express.d.ts +28 -0
- package/dist/server/lb-express.d.ts.map +1 -0
- package/dist/server/lb-express.js +77 -0
- package/dist/server/lb-server.d.ts +174 -0
- package/dist/server/lb-server.d.ts.map +1 -0
- package/dist/server/lb-server.js +79 -0
- package/dist/tests/assemble.test.d.ts +8 -0
- package/dist/tests/assemble.test.d.ts.map +1 -0
- package/dist/tests/assemble.test.js +51 -0
- package/dist/tests/elements.test.d.ts +8 -0
- package/dist/tests/elements.test.d.ts.map +1 -0
- package/dist/tests/elements.test.js +111 -0
- package/dist/tests/expand.test.d.ts +10 -0
- package/dist/tests/expand.test.d.ts.map +1 -0
- package/dist/tests/expand.test.js +226 -0
- package/dist/tests/fixtures/elements/collision/elements.d.ts +5 -0
- package/dist/tests/fixtures/elements/collision/elements.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/collision/elements.js +3 -0
- package/dist/tests/fixtures/elements/collision/widgets/acme-widget.d.ts +2 -0
- package/dist/tests/fixtures/elements/collision/widgets/acme-widget.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/collision/widgets/acme-widget.js +1 -0
- package/dist/tests/fixtures/elements/local/widgets/app-box.d.ts +2 -0
- package/dist/tests/fixtures/elements/local/widgets/app-box.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/local/widgets/app-box.js +1 -0
- package/dist/tests/fixtures/elements/manifest/elements.d.ts +5 -0
- package/dist/tests/fixtures/elements/manifest/elements.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/manifest/elements.js +3 -0
- package/dist/tests/fixtures/elements/manifest-bad-tag/elements.d.ts +5 -0
- package/dist/tests/fixtures/elements/manifest-bad-tag/elements.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/manifest-bad-tag/elements.js +3 -0
- package/dist/tests/fixtures/elements/manifest-bad-value/elements.d.ts +5 -0
- package/dist/tests/fixtures/elements/manifest-bad-value/elements.d.ts.map +1 -0
- package/dist/tests/fixtures/elements/manifest-bad-value/elements.js +3 -0
- package/dist/tests/golden.test.d.ts +19 -0
- package/dist/tests/golden.test.d.ts.map +1 -0
- package/dist/tests/golden.test.js +60 -0
- package/dist/tests/helpers/console.d.ts +20 -0
- package/dist/tests/helpers/console.d.ts.map +1 -0
- package/dist/tests/helpers/console.js +28 -0
- package/dist/tests/helpers/dom.d.ts +18 -0
- package/dist/tests/helpers/dom.d.ts.map +1 -0
- package/dist/tests/helpers/dom.js +22 -0
- package/dist/tests/helpers/window.d.ts +43 -0
- package/dist/tests/helpers/window.d.ts.map +1 -0
- package/dist/tests/helpers/window.js +78 -0
- package/dist/tests/lb-apply.test.d.ts +8 -0
- package/dist/tests/lb-apply.test.d.ts.map +1 -0
- package/dist/tests/lb-apply.test.js +153 -0
- package/dist/tests/lb-express.test.d.ts +14 -0
- package/dist/tests/lb-express.test.d.ts.map +1 -0
- package/dist/tests/lb-express.test.js +238 -0
- package/dist/tests/lb-input.test.d.ts +9 -0
- package/dist/tests/lb-input.test.d.ts.map +1 -0
- package/dist/tests/lb-input.test.js +78 -0
- package/dist/tests/lb-list.test.d.ts +12 -0
- package/dist/tests/lb-list.test.d.ts.map +1 -0
- package/dist/tests/lb-list.test.js +44 -0
- package/dist/tests/lb-options.test.d.ts +10 -0
- package/dist/tests/lb-options.test.d.ts.map +1 -0
- package/dist/tests/lb-options.test.js +121 -0
- package/dist/tests/lb-picker.test.d.ts +14 -0
- package/dist/tests/lb-picker.test.d.ts.map +1 -0
- package/dist/tests/lb-picker.test.js +59 -0
- package/dist/tests/lb-rows.test.d.ts +12 -0
- package/dist/tests/lb-rows.test.d.ts.map +1 -0
- package/dist/tests/lb-rows.test.js +336 -0
- package/dist/tests/lb-select.test.d.ts +9 -0
- package/dist/tests/lb-select.test.d.ts.map +1 -0
- package/dist/tests/lb-select.test.js +71 -0
- package/dist/tests/lb-server.test.d.ts +9 -0
- package/dist/tests/lb-server.test.d.ts.map +1 -0
- package/dist/tests/lb-server.test.js +495 -0
- package/dist/tests/lb-table.test.d.ts +15 -0
- package/dist/tests/lb-table.test.d.ts.map +1 -0
- package/dist/tests/lb-table.test.js +205 -0
- package/dist/tests/pages.test.d.ts +6 -0
- package/dist/tests/pages.test.d.ts.map +1 -0
- package/dist/tests/pages.test.js +98 -0
- package/dist/tests/styles.test.d.ts +7 -0
- package/dist/tests/styles.test.d.ts.map +1 -0
- package/dist/tests/styles.test.js +73 -0
- package/dist/widgets/index.d.ts +7 -0
- package/dist/widgets/index.d.ts.map +1 -0
- package/dist/widgets/index.js +6 -0
- package/dist/widgets/lb-input.d.ts +2 -0
- package/dist/widgets/lb-input.d.ts.map +1 -0
- package/dist/widgets/lb-input.js +48 -0
- package/dist/widgets/lb-list.d.ts +2 -0
- package/dist/widgets/lb-list.d.ts.map +1 -0
- package/dist/widgets/lb-list.js +17 -0
- package/dist/widgets/lb-options.d.ts +26 -0
- package/dist/widgets/lb-options.d.ts.map +1 -0
- package/dist/widgets/lb-options.js +72 -0
- package/dist/widgets/lb-picker.d.ts +2 -0
- package/dist/widgets/lb-picker.d.ts.map +1 -0
- package/dist/widgets/lb-picker.js +25 -0
- package/dist/widgets/lb-select.d.ts +2 -0
- package/dist/widgets/lb-select.d.ts.map +1 -0
- package/dist/widgets/lb-select.js +43 -0
- package/dist/widgets/lb-table.d.ts +2 -0
- package/dist/widgets/lb-table.d.ts.map +1 -0
- package/dist/widgets/lb-table.js +113 -0
- package/docs/application-chrome.md +36 -0
- package/docs/building-html-pages.md +130 -0
- package/docs/getting-started.md +120 -0
- package/docs/guide.md +1164 -0
- package/docs/hosting.md +218 -0
- package/docs/latent-risks.md +20 -0
- package/docs/theory.md +226 -0
- package/package.json +85 -0
- package/widgets/index.ts +6 -0
- package/widgets/lb-input.html +1 -0
- package/widgets/lb-input.ts +64 -0
- package/widgets/lb-list.html +1 -0
- package/widgets/lb-list.ts +21 -0
- package/widgets/lb-options.html +4 -0
- package/widgets/lb-options.ts +88 -0
- package/widgets/lb-picker.html +7 -0
- package/widgets/lb-picker.ts +27 -0
- package/widgets/lb-select.html +4 -0
- package/widgets/lb-select.ts +55 -0
- package/widgets/lb-table.html +8 -0
- package/widgets/lb-table.ts +126 -0
package/docs/hosting.md
ADDED
|
@@ -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
|
+
}
|
package/widgets/index.ts
ADDED
|
@@ -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);
|