@abide/abide 0.110.4 → 0.112.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 (55) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/docs/REGISTRY.md +30 -29
  3. package/docs/RULEBOOK.md +98 -22
  4. package/docs/abide.md +46 -32
  5. package/package.json +1 -1
  6. package/src/compiler/Lowering.ts +5 -0
  7. package/src/compiler/classifyTemplate.ts +129 -34
  8. package/src/compiler/compileApp.ts +2 -1
  9. package/src/compiler/emitClient.ts +128 -14
  10. package/src/compiler/renderMarkup.ts +14 -8
  11. package/src/server/BRIDGE_VERSION.ts +1 -1
  12. package/src/server/HEARTBEAT_MILLISECONDS.ts +2 -0
  13. package/src/server/IDLE_TIMEOUT_SECONDS.ts +6 -0
  14. package/src/server/answerRequest.ts +44 -3
  15. package/src/server/answerRpc.ts +14 -41
  16. package/src/server/answerSocket.ts +2 -2
  17. package/src/server/answerValue.ts +15 -3
  18. package/src/server/answerWorker.ts +195 -0
  19. package/src/server/appManifest.ts +48 -0
  20. package/src/server/assistant.ts +1 -1
  21. package/src/server/bridgeRelay.ts +4 -50
  22. package/src/server/claudeApi.ts +1 -1
  23. package/src/server/claudeCode.ts +3 -3
  24. package/src/server/clientBundle.ts +7 -0
  25. package/src/server/config.ts +14 -0
  26. package/src/server/createApp.ts +28 -4
  27. package/src/server/devWorker.ts +4 -1
  28. package/src/server/render.ts +2 -2
  29. package/src/server/requestScope.ts +31 -11
  30. package/src/server/responseRelay.ts +7 -7
  31. package/src/server/serveBuild.ts +2 -0
  32. package/src/server/serveDev.ts +4 -1
  33. package/src/server/streamEvent.ts +60 -1
  34. package/src/shared/ADDRESSES.ts +11 -0
  35. package/src/shared/ASCII_WHITESPACE.ts +6 -0
  36. package/src/shared/LONGEST_DELAY.ts +2 -0
  37. package/src/shared/OFFLINE_CACHES.ts +12 -0
  38. package/src/shared/OFFLINE_META.ts +7 -0
  39. package/src/shared/answerOf.ts +29 -4
  40. package/src/shared/attributeText.ts +1 -1
  41. package/src/shared/reactive/Expiry.ts +13 -1
  42. package/src/shared/reactive/Reactive.ts +13 -3
  43. package/src/shared/safeUrl.ts +8 -1
  44. package/src/ui/Level.ts +43 -95
  45. package/src/ui/Router.ts +37 -13
  46. package/src/ui/bindGroup.ts +41 -0
  47. package/src/ui/bindProperty.ts +1 -21
  48. package/src/ui/index.ts +5 -3
  49. package/src/ui/reconcileHead.ts +215 -0
  50. package/src/ui/registerWorker.ts +34 -0
  51. package/src/ui/routing.ts +1 -0
  52. package/src/ui/setClassGroup.ts +79 -0
  53. package/src/ui/setStyleGroup.ts +130 -0
  54. package/src/ui/socketWire.ts +33 -2
  55. package/src/ui/applyHead.ts +0 -214
@@ -0,0 +1,130 @@
1
+ import { oneStyleValue } from '#shared/escapeText.ts'
2
+ import { textOf } from '#shared/Raw.ts'
3
+ import { WORK } from '#shared/WORK.ts'
4
+
5
+ /**
6
+ * 32.71 — what a `style={expr}` and the `style:prop` directives beside it last wrote, as one
7
+ * group: the attribute's text, its declarations once they have been needed, and the directives'
8
+ * values in the order `names` gives them.
9
+ *
10
+ * KEYED BY ELEMENT rather than declared by the mount, because a row's `update` writes the same
11
+ * element from a function of its own and both have to read one record.
12
+ */
13
+ const owned = new WeakMap<
14
+ Element,
15
+ {
16
+ text: string | undefined
17
+ declared: Map<string, [value: string, priority: string]> | undefined
18
+ values: readonly unknown[] | undefined
19
+ names: string[]
20
+ }
21
+ >()
22
+
23
+ // THE PARSE IS THE BROWSER'S OWN: a declaration list is not split on `;` by hand, since one can sit
24
+ // inside `url()` or a string. A detached element's style is parsed and never laid out.
25
+ let parsed: CSSStyleDeclaration | undefined
26
+
27
+ /** A declaration list's properties, values and priorities, as the browser parses it. */
28
+ function declarationsOf(
29
+ text: string,
30
+ ): Map<string, [value: string, priority: string]> {
31
+ parsed ??= document.createElement('div').style
32
+ parsed.cssText = text
33
+ const declared = new Map<string, [value: string, priority: string]>()
34
+ for (let index = 0; index < parsed.length; index += 1) {
35
+ const property = parsed.item(index)
36
+ declared.set(property, [
37
+ parsed.getPropertyValue(property),
38
+ parsed.getPropertyPriority(property),
39
+ ])
40
+ }
41
+ return declared
42
+ }
43
+
44
+ /**
45
+ * Writes a `style={expr}` and the `style:prop` directives beside it as one group, through the
46
+ * CSSOM: `plain` is the attribute's value, and `values` each directive's, in the order of `names`,
47
+ * its properties space-separated.
48
+ *
49
+ * 32.71 — ONLY ITS OWN PROPERTIES, in the server's cascade: the attribute's declarations, then
50
+ * each directive's in the tag's order (`styleValue`), a later one winning and one with no value
51
+ * declaring nothing. Only what moved is written. The attribute is parsed only when its text
52
+ * changed — never on the first run, whose value the served page or built row already carries —
53
+ * and every write is guarded by a compare. Where the attribute changed or a directive emptied,
54
+ * the cascade is laid again from the attribute, which is how a shorthand on one side and a
55
+ * longhand on the other resolve as they did in the server's list.
56
+ */
57
+ export function setStyleGroup(
58
+ element: Element,
59
+ names: string,
60
+ plain: unknown,
61
+ values: readonly unknown[],
62
+ ): void {
63
+ WORK.bindingRuns += 1
64
+ let held = owned.get(element)
65
+ if (held === undefined) {
66
+ held = {
67
+ text: undefined,
68
+ declared: undefined,
69
+ values: undefined,
70
+ names: names.split(' '),
71
+ }
72
+ owned.set(element, held)
73
+ }
74
+ const style = (element as HTMLElement).style
75
+ const text = textOf(plain)
76
+ const was = held.values
77
+ // The first directive to write again, every one after it too: a later one wins.
78
+ let from = -1
79
+ if (held.text === undefined) held.text = text
80
+ else if (text !== held.text) {
81
+ const before = held.declared ?? declarationsOf(held.text)
82
+ const after = declarationsOf(text)
83
+ for (const property of before.keys())
84
+ if (!after.has(property) && style.getPropertyValue(property) !== '')
85
+ style.removeProperty(property)
86
+ lay(style, after)
87
+ held.text = text
88
+ held.declared = after
89
+ from = 0
90
+ }
91
+ for (let at = 0; at < held.names.length && from !== 0; at += 1) {
92
+ const value = directiveValue(values[at])
93
+ if (was !== undefined && directiveValue(was[at]) === value) continue
94
+ if (value !== '' || was === undefined) {
95
+ if (from === -1 && value !== '') from = at
96
+ continue
97
+ }
98
+ const name = held.names[at] as string
99
+ if (style.getPropertyValue(name) !== '') style.removeProperty(name)
100
+ held.declared ??= declarationsOf(held.text)
101
+ lay(style, held.declared)
102
+ from = 0
103
+ }
104
+ if (from !== -1)
105
+ for (let at = from; at < held.names.length; at += 1) {
106
+ const value = directiveValue(values[at])
107
+ const name = held.names[at] as string
108
+ if (value !== '' && style.getPropertyValue(name) !== value)
109
+ style.setProperty(name, value)
110
+ }
111
+ held.values = values
112
+ }
113
+
114
+ /** A directive's value, cut as the server cuts it; `''` declares nothing. */
115
+ function directiveValue(value: unknown): string {
116
+ return oneStyleValue(textOf(value))
117
+ }
118
+
119
+ /** Writes each declaration the element does not already carry. */
120
+ function lay(
121
+ style: CSSStyleDeclaration,
122
+ declared: Map<string, [value: string, priority: string]>,
123
+ ): void {
124
+ for (const [property, [value, priority]] of declared)
125
+ if (
126
+ style.getPropertyValue(property) !== value ||
127
+ style.getPropertyPriority(property) !== priority
128
+ )
129
+ style.setProperty(property, value, priority)
130
+ }
@@ -133,6 +133,7 @@ class Mux {
133
133
  subscriptions: Map<number, Following>
134
134
  nextId: number
135
135
  retry: number
136
+ retrying: ReturnType<typeof setTimeout> | undefined
136
137
 
137
138
  constructor(url: string) {
138
139
  this.url = url
@@ -140,7 +141,23 @@ class Mux {
140
141
  this.subscriptions = new Map()
141
142
  this.nextId = 1
142
143
  this.retry = RETRY_FLOOR_MS
144
+ this.retrying = undefined
143
145
  onPrincipalRotated(() => this.reopen(), true)
146
+ // 18.33 — THE BACKOFF IS FOR A SERVER THAT IS DOWN, not for a network that has just come
147
+ // back or a tab whose timers the browser was throttling while it was hidden.
148
+ addEventListener('online', () => this.redial())
149
+ document.addEventListener('visibilitychange', () => {
150
+ if (document.visibilityState === 'visible') this.redial()
151
+ })
152
+ }
153
+
154
+ /** Opens a closed socket now, in place of the retry waiting out its backoff. */
155
+ redial(): void {
156
+ if (this.socket !== null || this.subscriptions.size === 0) return
157
+ clearTimeout(this.retrying)
158
+ this.retrying = undefined
159
+ this.retry = RETRY_FLOOR_MS
160
+ this.connect()
144
161
  }
145
162
 
146
163
  /**
@@ -188,11 +205,25 @@ class Mux {
188
205
  socket.onclose = () => {
189
206
  this.socket = null
190
207
  if (this.subscriptions.size === 0) return
191
- setTimeout(() => this.connect(), this.retry)
192
- this.retry = Math.min(this.retry * 2, RETRY_CEILING_MS)
208
+ this.retryLater()
193
209
  }
194
210
  }
195
211
 
212
+ /**
213
+ * Arms the next attempt at the backoff standing, and doubles it.
214
+ *
215
+ * 18.34 — OFFLINE, AN ATTEMPT CANNOT SUCCEED, so none is armed: the `online` event's `redial`
216
+ * makes the next one.
217
+ */
218
+ retryLater(): void {
219
+ if (!navigator.onLine) return
220
+ this.retrying = setTimeout(() => {
221
+ this.retrying = undefined
222
+ this.connect()
223
+ }, this.retry)
224
+ this.retry = Math.min(this.retry * 2, RETRY_CEILING_MS)
225
+ }
226
+
196
227
  receive(frame: ServerFrame): void {
197
228
  this.subscriptions.get(frame.id)?.receive(frame)
198
229
  }
@@ -1,214 +0,0 @@
1
- // 39.6 — ON A CLIENT NAVIGATION THE INCOMING PAGE'S KEYED CONTRIBUTIONS REPLACE THE OUTGOING
2
- // PAGE'S BY KEY. The outgoing page's were written by a server on the first load and by this
3
- // function on every one after, so they are found the same way either time: by their key, read off
4
- // the document. Nothing is added to the head to mark ownership — a marker would be a fact about
5
- // the framework in a document an author writes, and the key is already what decides.
6
- //
7
- // 39.5 — A CONTRIBUTION WITH NO KEY APPENDS. It cannot replace anything ANOTHER contributor wrote,
8
- // because there is nothing it could be said to be the same as, and dropping it instead would lose
9
- // the one kind of contribution a page makes several of. What its OWN contributor wrote last is
10
- // another matter: a head written again puts its keyless nodes where the last ones stood (39.15),
11
- // and a head withdrawn takes them with it (39.16). Found the way a key is — off the document, by
12
- // the markup the contributor last wrote — so a head the server painted is found the same as one
13
- // written here.
14
-
15
- import { headKeyOf } from '#shared/headKeyOf.ts'
16
- import { markupNodes } from './parseMarkup.ts'
17
-
18
- /**
19
- * Writes `markup`'s contributions into the document head, a key `below` holds excepted.
20
- *
21
- * 39.4 — `below` is the head of every contributor deeper than the one writing, which a head
22
- * re-applied live (39.13) must not undo: the deepest contributor wins whichever of them moved last.
23
- *
24
- * 39.15 — `last` is what the same contributor wrote before, whose keyless nodes this replaces in
25
- * place, in order, rather than adding beside them. 39.14 — a key `last` held that `markup` does not
26
- * is withdrawn to `outer`'s value for it, as a leaving contributor's is; a key list that did not
27
- * move withdraws nothing and never asks `outer`.
28
- */
29
- export function applyHead(
30
- markup: string,
31
- below = '',
32
- last = '',
33
- outer: () => string = () => '',
34
- ): void {
35
- const head = document.head
36
- if (markup === '') return
37
- const incoming = markupNodes(head, markup)
38
- const standingByKey = standingKeys(head)
39
- const deeper = below === '' ? undefined : keyedIn(head, below)
40
- const replaced: Node[] = []
41
- const withdrawn = keyedIn(head, last, replaced)
42
- let next = 0
43
- for (const node of incoming) {
44
- const key = node.nodeType === 1 ? headKey(node as Element) : undefined
45
- if (key === undefined) {
46
- const standing = replaced[next]
47
- next += 1
48
- if (standing === undefined) head.appendChild(runnable(node))
49
- // The same node left standing, as a key's is: a `<style>` put back as itself is not
50
- // re-parsed into the cascade, and a `<script>` is not run twice.
51
- else if (!standing.isEqualNode(node))
52
- head.replaceChild(runnable(node), standing)
53
- continue
54
- }
55
- const element = node as Element
56
- withdrawn.delete(key)
57
- if (deeper?.has(key) === true) continue
58
- const standing = standingByKey.get(key)
59
- if (standing === undefined) {
60
- head.appendChild(element)
61
- standingByKey.set(key, element)
62
- continue
63
- }
64
- // WHAT THE TWO PAGES SHARE IS NOT TOUCHED. `hydrate` skips `applyHead` outright because
65
- // re-applying a `<link rel=stylesheet>` refetches it and flashes the document unstyled —
66
- // and a navigation between two pages carrying the same stylesheet, viewport or icon walks
67
- // into that same cost per contribution. An identical node is the one case where the
68
- // cheaper arm is to do nothing at all.
69
- if (standing.outerHTML === element.outerHTML) continue
70
- head.replaceChild(element, standing)
71
- standingByKey.set(key, element)
72
- }
73
- for (; next < replaced.length; next += 1)
74
- (replaced[next] as Node).parentNode?.removeChild(replaced[next] as Node)
75
- if (withdrawn.size === 0) return
76
- if (deeper !== undefined)
77
- for (const key of deeper.keys()) withdrawn.delete(key)
78
- restoreKeys(head, withdrawn, outer())
79
- }
80
-
81
- /**
82
- * `node`, or — for a `<script>` — the same script made by the document rather than the parser.
83
- *
84
- * 39.17 — A PARSED SCRIPT NEVER RUNS. The fragment parser marks every `<script>` it makes "already
85
- * started", and `parseMarkup` is that parser, so a script it made and then moved into the head is
86
- * inert where the server's, made by the document's own parser, ran. One made by `createElement`
87
- * starts clear and runs on insertion. Keyless always (39.11 keys no `<script>`), so this is reached
88
- * only where a keyless contribution is actually written.
89
- */
90
- function runnable(node: Node): Node {
91
- if (node.nodeName !== 'SCRIPT') return node
92
- const parsed = node as HTMLScriptElement
93
- const script = document.createElement('script')
94
- const attributes = parsed.attributes
95
- for (let index = 0; index < attributes.length; index += 1) {
96
- const attribute = attributes[index] as Attr
97
- script.setAttribute(attribute.name, attribute.value)
98
- }
99
- script.text = parsed.text
100
- return script
101
- }
102
-
103
- /**
104
- * Puts back, or removes, what the leaving levels keyed and the arriving ones do not.
105
- *
106
- * 39.14 — THE NEXT-OUTER CONTRIBUTOR'S VALUE, OR NONE. `leaving` is the head of the levels a
107
- * navigation stopped; `around` answers `outer`, every contributor standing above them, and
108
- * `arriving`, the heads of the levels it mounted. `outer` runs outermost first — the shell's keyed
109
- * entries, then each kept layout's — so the LAST entry for a key in it is the one the server would
110
- * paint there (39.4). A component leaving its region is withdrawn the same way, its level's
111
- * contributors either side of it standing in for the two (`Level.headsAround`). What the arriving levels key they have
112
- * already written (39.6), so only the keys they are silent on are touched — and 39.16, the leaving
113
- * levels' keyless nodes leave with them.
114
- *
115
- * THE COMMON NAVIGATION WITHDRAWS NOTHING BY KEY: two pages that both key a `<title>` leave
116
- * `outer` built but never parsed, and a head keying nothing never asks `around`.
117
- */
118
- export function withdrawHead(
119
- leaving: string,
120
- around: () => [outer: string, arriving: string],
121
- ): void {
122
- if (leaving === '') return
123
- const head = document.head
124
- const keyless: Node[] = []
125
- const withdrawn = keyedIn(head, leaving, keyless)
126
- for (const node of keyless) node.parentNode?.removeChild(node)
127
- if (withdrawn.size === 0) return
128
- const [outer, arriving] = around()
129
- for (const key of keyedIn(head, arriving).keys()) withdrawn.delete(key)
130
- restoreKeys(head, withdrawn, outer)
131
- }
132
-
133
- /** 39.14 — each of `withdrawn`'s keys back to `outer`'s last value for it, or removed. */
134
- function restoreKeys(
135
- head: HTMLHeadElement,
136
- withdrawn: Map<string, Element>,
137
- outer: string,
138
- ): void {
139
- if (withdrawn.size === 0) return
140
- const restored = keyedIn(head, outer)
141
- const standingByKey = standingKeys(head)
142
- for (const key of withdrawn.keys()) {
143
- const standing = standingByKey.get(key)
144
- const next = restored.get(key)
145
- if (next === undefined) standing?.remove()
146
- else if (standing === undefined) head.appendChild(next)
147
- // The same node as `applyHead`'s: a stylesheet put back as itself is not refetched.
148
- else if (standing.outerHTML !== next.outerHTML)
149
- head.replaceChild(next, standing)
150
- }
151
- }
152
-
153
- /**
154
- * The keyed elements `markup` parses to, by key, the last holding each (39.4).
155
- *
156
- * Where `standing` is given, each keyless node is matched to the first node standing in the head
157
- * equal to it and not already matched, and the match pushed there (39.15, 39.16).
158
- */
159
- function keyedIn(
160
- head: HTMLHeadElement,
161
- markup: string,
162
- standing?: Node[],
163
- ): Map<string, Element> {
164
- const keyed = new Map<string, Element>()
165
- if (markup === '') return keyed
166
- for (const node of markupNodes(head, markup)) {
167
- const key = node.nodeType === 1 ? headKey(node as Element) : undefined
168
- if (key !== undefined) keyed.set(key, node as Element)
169
- else if (standing !== undefined)
170
- for (let at = head.firstChild; at !== null; at = at.nextSibling)
171
- if (at.isEqualNode(node) && !standing.includes(at)) {
172
- standing.push(at)
173
- break
174
- }
175
- }
176
- return keyed
177
- }
178
-
179
- /**
180
- * The head's keyed elements by key, the first holding each.
181
- *
182
- * KEYED ONCE, NOT ONCE PER CONTRIBUTION. Walking the head per incoming node asked `headKey` — a
183
- * `toLowerCase` and a fresh closure per call — for every standing element times every arriving
184
- * one. One pass builds the index and each contribution is then a lookup.
185
- */
186
- function standingKeys(head: HTMLHeadElement): Map<string, Element> {
187
- const standingByKey = new Map<string, Element>()
188
- for (
189
- let node = head.firstElementChild;
190
- node !== null;
191
- node = node.nextElementSibling
192
- ) {
193
- const key = headKey(node)
194
- // 39.12 WITHIN THE STANDING HEAD: the FIRST element holding a key is the one a
195
- // contribution replaces, which is the node the per-contribution walk returned.
196
- if (key !== undefined && !standingByKey.has(key))
197
- standingByKey.set(key, node)
198
- }
199
- return standingByKey
200
- }
201
-
202
- /**
203
- * The key a head element replaces by, or `undefined` for one that appends.
204
- *
205
- * 39.11 THROUGH THE ONE GRAMMAR (`headKeyOf`), asked with a RESOLVED attribute. The compiler
206
- * answers the same question for a STATIC one and appends where it cannot (`<meta name={x}>` has a
207
- * name only the run time knows) — here every attribute is resolved, so a contribution the compiler
208
- * had to append arrives with a key. That is the same rule reaching further, not a different one.
209
- */
210
- function headKey(element: Element): string | undefined {
211
- return headKeyOf(element.tagName.toLowerCase(), (name) =>
212
- element.getAttribute(name),
213
- )
214
- }