@abide/abide 0.110.4 → 0.111.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 (41) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/docs/REGISTRY.md +22 -21
  3. package/docs/RULEBOOK.md +85 -19
  4. package/docs/abide.md +38 -24
  5. package/package.json +1 -1
  6. package/src/compiler/compileApp.ts +2 -1
  7. package/src/server/BRIDGE_VERSION.ts +1 -1
  8. package/src/server/HEARTBEAT_MILLISECONDS.ts +2 -0
  9. package/src/server/IDLE_TIMEOUT_SECONDS.ts +6 -0
  10. package/src/server/answerRequest.ts +44 -3
  11. package/src/server/answerRpc.ts +14 -41
  12. package/src/server/answerSocket.ts +2 -2
  13. package/src/server/answerValue.ts +15 -3
  14. package/src/server/answerWorker.ts +195 -0
  15. package/src/server/appManifest.ts +48 -0
  16. package/src/server/assistant.ts +1 -1
  17. package/src/server/bridgeRelay.ts +4 -50
  18. package/src/server/claudeApi.ts +1 -1
  19. package/src/server/claudeCode.ts +3 -3
  20. package/src/server/clientBundle.ts +7 -0
  21. package/src/server/config.ts +14 -0
  22. package/src/server/createApp.ts +28 -4
  23. package/src/server/devWorker.ts +4 -1
  24. package/src/server/requestScope.ts +31 -11
  25. package/src/server/responseRelay.ts +7 -7
  26. package/src/server/serveBuild.ts +2 -0
  27. package/src/server/serveDev.ts +4 -1
  28. package/src/server/streamEvent.ts +60 -1
  29. package/src/shared/ADDRESSES.ts +11 -0
  30. package/src/shared/LONGEST_DELAY.ts +2 -0
  31. package/src/shared/OFFLINE_CACHES.ts +12 -0
  32. package/src/shared/OFFLINE_META.ts +7 -0
  33. package/src/shared/answerOf.ts +29 -4
  34. package/src/shared/attributeText.ts +1 -1
  35. package/src/shared/reactive/Expiry.ts +13 -1
  36. package/src/shared/reactive/Reactive.ts +13 -3
  37. package/src/shared/safeUrl.ts +8 -1
  38. package/src/ui/Router.ts +25 -0
  39. package/src/ui/registerWorker.ts +34 -0
  40. package/src/ui/routing.ts +1 -0
  41. package/src/ui/socketWire.ts +33 -2
@@ -24,6 +24,7 @@ import {
24
24
  PUBLIC_DIRECTORY,
25
25
  } from './CLIENT_BUILD.ts'
26
26
  import { bundleServer, type ServedFile } from './clientBundle.ts'
27
+ import { settingOn } from './config.ts'
27
28
  import type { Manifest } from './manifestOf.ts'
28
29
  import { namesUnder } from './namesUnder.ts'
29
30
  import { ENCODINGS } from './negotiateEncoding.ts'
@@ -137,6 +138,7 @@ export function builtSources(parts: BuildParts): Promise<AppSources> {
137
138
  files,
138
139
  'public, max-age=31536000, immutable',
139
140
  ),
141
+ offline: settingOn('ABIDE_OFFLINE', true),
140
142
  },
141
143
  })
142
144
  }
@@ -31,6 +31,7 @@ import { DEV_ADDRESS } from '#shared/ADDRESSES.ts'
31
31
  import type { Compilation } from './abideModules.ts'
32
32
  import { DEFAULT_PORT } from './DEFAULT_PORT.ts'
33
33
  import type { FromWorker, ToWorker } from './devWorker.ts'
34
+ import { IDLE_TIMEOUT_SECONDS } from './IDLE_TIMEOUT_SECONDS.ts'
34
35
  import { muxSocketOptions, sendOrClose, upgraded } from './muxUpgrade.ts'
35
36
  import { placeOf } from './placement.ts'
36
37
  import { plainAnswer } from './plainAnswer.ts'
@@ -77,7 +78,7 @@ export async function serveDev(
77
78
  options: DevOptions,
78
79
  ): Promise<Bun.Server<undefined>> {
79
80
  // 38.24, 38.35 — every request, asked of the worker there is and streamed back a chunk per read.
80
- // 16.22 — a call's idle timeout raised on the request bun handed this listener.
81
+ // 16.23 — a request's idle timeout set on the request bun handed this listener.
81
82
  const relay = askOver((request, seconds) =>
82
83
  served?.timeout(request, seconds),
83
84
  )
@@ -302,6 +303,8 @@ export async function serveDev(
302
303
  ...(port === undefined ? {} : { port }),
303
304
  // 16.54, as `App.listen` lifts it: the worker behind this is where the ceiling is read.
304
305
  maxRequestBodySize: Number.MAX_SAFE_INTEGER,
306
+ // 16.56 — the bound a request received whole lifts and its answer puts back (16.23).
307
+ idleTimeout: IDLE_TIMEOUT_SECONDS,
305
308
  async fetch(request, listening) {
306
309
  const asked = new URL(request.url).pathname
307
310
  // THE MUX STANDS UNDER THE MOUNT, which is the app's configuration and so the worker's to
@@ -5,9 +5,68 @@
5
5
  import type { Problem } from '#shared/Failed.ts'
6
6
  import type { Framed } from '#shared/framing.ts'
7
7
  import { REFUSAL_KEY } from '#shared/REFUSAL_KEY.ts'
8
+ import { HEARTBEAT_MILLISECONDS } from './HEARTBEAT_MILLISECONDS.ts'
8
9
 
9
10
  /** AN EVENT STREAM OPENS ON A COMMENT, so a proxy has bytes to pass before the first event. */
10
- export const EVENT_STREAM_OPENER = ':\n\n'
11
+ export const EVENT_STREAM_OPENER = new TextEncoder().encode(':\n\n')
12
+
13
+ /**
14
+ * `body`, read one chunk a pull; `release` runs once it closes, errors or its reader hangs up.
15
+ *
16
+ * 16.57 — `beats`: a comment wherever a read has waited a heartbeat for its next chunk. A COMMENT
17
+ * RATHER THAN AN EVENT: every reader skips one (18.27), and it gives a proxy in front bytes to
18
+ * pass, so neither it nor the reader takes a quiet stream for a dead one. A bridge's reader goes by
19
+ * it too (44.27), where a frame would sit in the pairing's inbox through a drop.
20
+ *
21
+ * BEATS ONLY WHILE A READ WAITS, at a high-water mark of 0: a reader that stalled or never started
22
+ * pulls nothing, so no comment queues behind it and no timer outlives it, and `body` is pulled no
23
+ * further ahead than it was. A read the beat won is kept for the next pull.
24
+ */
25
+ export function heldBody(
26
+ body: ReadableStream<Uint8Array>,
27
+ release: () => void,
28
+ beats: boolean,
29
+ ): ReadableStream<Uint8Array> {
30
+ const reader = body.getReader()
31
+ let pending: ReturnType<typeof reader.read> | undefined
32
+ return new ReadableStream<Uint8Array>(
33
+ {
34
+ async pull(controller) {
35
+ pending ??= reader.read()
36
+ let beat: ReturnType<typeof setTimeout> | undefined
37
+ try {
38
+ const next = beats
39
+ ? await Promise.race([
40
+ pending,
41
+ new Promise<undefined>((quiet) => {
42
+ beat = setTimeout(
43
+ () => quiet(undefined),
44
+ HEARTBEAT_MILLISECONDS,
45
+ )
46
+ }),
47
+ ])
48
+ : await pending
49
+ if (next === undefined)
50
+ return controller.enqueue(EVENT_STREAM_OPENER)
51
+ pending = undefined
52
+ if (!next.done) return controller.enqueue(next.value)
53
+ controller.close()
54
+ release()
55
+ } catch (thrown) {
56
+ controller.error(thrown)
57
+ release()
58
+ } finally {
59
+ clearTimeout(beat)
60
+ }
61
+ },
62
+ cancel(reason) {
63
+ release()
64
+ return reader.cancel(reason)
65
+ },
66
+ },
67
+ { highWaterMark: 0 },
68
+ )
69
+ }
11
70
 
12
71
  /**
13
72
  * One event: its `event` name and `id` where it has them, and `data` — one line, which JSON is as
@@ -59,3 +59,14 @@ export const SOCKET_ADDRESS = `${ABIDE_ADDRESS}/socket`
59
59
  * 26.2.
60
60
  */
61
61
  export const PRINCIPAL_ADDRESS = `${ABIDE_ADDRESS}/principal`
62
+
63
+ /**
64
+ * Where an app with `ABIDE_OFFLINE` on serves its service worker, and where every other app serves
65
+ * the one that removes it.
66
+ *
67
+ * 45.1, 45.9.
68
+ */
69
+ export const WORKER_ADDRESS = `${ABIDE_ADDRESS}/worker.js`
70
+
71
+ /** The web app manifest an app answers where it keeps its pages offline and has an icon (45.15). */
72
+ export const APP_MANIFEST_ADDRESS = `${ABIDE_ADDRESS}/manifest.webmanifest`
@@ -0,0 +1,2 @@
1
+ /** The longest delay `setTimeout` holds, in milliseconds: it reads anything past a signed 32-bit count as 1 ms. */
2
+ export const LONGEST_DELAY = 2 ** 31 - 1
@@ -0,0 +1,12 @@
1
+ // 45.3, 45.5 — THE CACHES THE SERVICE WORKER KEEPS, named once for the worker the server writes and
2
+ // the router that asks whether a page was saved (45.12). A leaf, for the reason every constant
3
+ // crossing a seam is one.
4
+
5
+ /** What every cache the worker keeps is named under, so a cache an earlier worker named is found. */
6
+ export const OFFLINE_CACHE_PREFIX = 'abide:'
7
+
8
+ /** The pages, the handlers' answers and the public files the worker saved, by address. */
9
+ export const OFFLINE_SAVED = `${OFFLINE_CACHE_PREFIX}saved`
10
+
11
+ /** The build's bundle, saved as the worker installs. */
12
+ export const OFFLINE_BUNDLE = `${OFFLINE_CACHE_PREFIX}bundle`
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The `<meta>` the server writes unless `ABIDE_OFFLINE` is off, and a browser registers the service
3
+ * worker by.
4
+ *
5
+ * 45.1.
6
+ */
7
+ export const OFFLINE_META = 'abide-offline'
@@ -81,15 +81,40 @@ async function fetched(
81
81
  streams: boolean,
82
82
  signal: AbortSignal | undefined,
83
83
  ): Promise<Answer> {
84
- const response = await fetch(
85
- request.url,
86
- signal === undefined ? request.init : { ...request.init, signal },
87
- )
84
+ let response: Response
85
+ try {
86
+ response = await fetch(
87
+ request.url,
88
+ signal === undefined ? request.init : { ...request.init, signal },
89
+ )
90
+ } catch (error) {
91
+ // An abort is the load's own ending (16.49), which the value has already answered.
92
+ if (signal?.aborted === true) throw error
93
+ // 16.79 — NO ANSWER AT ALL, as a status the app can narrow on rather than the browser's
94
+ // own `TypeError`, whose message differs in each.
95
+ return unanswered()
96
+ }
88
97
  // THE HANDLER SIGNED THIS CALLER IN OR OUT, and the cookie is already the browser's.
89
98
  principalRotated(response)
90
99
  return answerFrom(response, undefined, streams)
91
100
  }
92
101
 
102
+ /** The answer a call that reached no server stands in for: an empty 503, which `answered` refuses. */
103
+ function unanswered(): Answer {
104
+ return {
105
+ status: 503,
106
+ statusText: '',
107
+ ok: false,
108
+ framing: null,
109
+ followable: false,
110
+ version: undefined,
111
+ life: undefined,
112
+ text: '',
113
+ bytes: undefined,
114
+ body: undefined,
115
+ }
116
+ }
117
+
93
118
  /**
94
119
  * `response`, classified: a body `read` to its end already (an early call the server painted)
95
120
  * decoded as what it carried, and one unread read as bytes where it is a binary (16.34), as text
@@ -31,7 +31,7 @@ export function attributeText(name: string, value: unknown): string | null {
31
31
  const safe = safeUrl(text)
32
32
  if (safe !== text)
33
33
  RENDER_LOG.warning(
34
- `abide: \`${name}\` was handed a URL whose scheme is not http:, https:, mailto: or tel:, and is written empty. RULEBOOK 32.20.`,
34
+ `abide: \`${name}\` was handed a URL whose scheme is not http:, https:, mailto:, tel:, blob: or data:, and is written empty. RULEBOOK 32.20.`,
35
35
  )
36
36
  return safe
37
37
  }
@@ -38,6 +38,8 @@
38
38
  // nothing is dropped early, and the read-driven backstop in `Reactive.ts` catches up the
39
39
  // moment the clock does.
40
40
 
41
+ import { LONGEST_DELAY } from '../LONGEST_DELAY.ts'
42
+
41
43
  export type Expiry = {
42
44
  /** The life of one production, in milliseconds. */
43
45
  ms: number
@@ -90,13 +92,23 @@ export function expiryFor(
90
92
  * number and has no process to hold open, so the clause is satisfied there by there being nothing
91
93
  * to satisfy. CLAUDE.md's uniformity bias is exactly this shape — one mechanism, inert on the side
92
94
  * that cannot need it.
95
+ *
96
+ * PAST WHAT `setTimeout` HOLDS — 2^31 - 1 ms, near 25 days — a delay is run after 1 ms, and a
97
+ * deadline that fires early finds nothing expired and re-arms: a timer a millisecond for the life of
98
+ * the value. So `Infinity` arms nothing, and a longer finite delay arms at the cap — early, which a
99
+ * deadline re-checks and re-arms from, and which a debounce or throttle window that long takes as
100
+ * its end.
93
101
  */
94
102
  export function armTimer(
95
103
  expiry: { timer: ReturnType<typeof setTimeout> | null },
96
104
  delay: number,
97
105
  fire: () => void,
98
106
  ): void {
99
- const timer = setTimeout(fire, delay > 0 ? delay : 0)
107
+ if (delay === Number.POSITIVE_INFINITY) return
108
+ const timer = setTimeout(
109
+ fire,
110
+ delay > 0 ? Math.min(delay, LONGEST_DELAY) : 0,
111
+ )
100
112
  const unref = (timer as { unref?: () => void }).unref
101
113
  if (typeof unref === 'function') unref.call(timer)
102
114
  expiry.timer = timer
@@ -1998,7 +1998,16 @@ function refused(cell: Cell, failure: unknown): unknown {
1998
1998
  // had set it true, and `error` went from nothing to this. A guard that asked about
1999
1999
  // either is woken here or never.
2000
2000
  probesMoved(cell)
2001
- if (!cell.servable) cascade(cell)
2001
+ if (cell.servable) return failure
2002
+ // 7.19 — A SERVER THAT COULD NOT BE REACHED (16.79) says nothing about the value held, so the
2003
+ // invalidated value is served again rather than lost to a read that throws, as 4.12 serves one.
2004
+ if (
2005
+ cell.landed &&
2006
+ isFailedNamed(failure, 'HttpError') &&
2007
+ (failure as Failed<string, unknown>).status === 503
2008
+ )
2009
+ serveAgain(cell)
2010
+ else cascade(cell)
2002
2011
  return failure
2003
2012
  }
2004
2013
 
@@ -3253,7 +3262,9 @@ function settled(cell: Cell, untilStreaming = false): Promise<unknown> {
3253
3262
  if (untilStreaming && cell.stream !== null)
3254
3263
  return Promise.resolve(STREAMING)
3255
3264
  if (cell.servable && !busyNow(cell)) return Promise.resolve(heldValue(cell))
3256
- if (!cell.landed && !busyNow(cell)) {
3265
+ // 2.8 — UNSERVED, NOT UNLANDED: an invalidated value has landed (7.8), and a reload that failed
3266
+ // over it leaves the failure as the answer, or the await waits on a load that has ended.
3267
+ if (!busyNow(cell)) {
3257
3268
  const failure = failureOf(cell)
3258
3269
  if (failure !== undefined) return Promise.reject(failure)
3259
3270
  }
@@ -3280,7 +3291,6 @@ function settled(cell: Cell, untilStreaming = false): Promise<unknown> {
3280
3291
  settle(heldValue(cell))
3281
3292
  return
3282
3293
  }
3283
- if (cell.landed) return
3284
3294
  const failure = failureOf(cell)
3285
3295
  if (failure !== undefined) {
3286
3296
  waiter.alive = false
@@ -8,7 +8,14 @@
8
8
  // attribute's name.
9
9
  import { textOf } from './Raw.ts'
10
10
 
11
- const SAFE_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:'])
11
+ const SAFE_SCHEMES = new Set([
12
+ 'http:',
13
+ 'https:',
14
+ 'mailto:',
15
+ 'tel:',
16
+ 'blob:',
17
+ 'data:',
18
+ ])
12
19
 
13
20
  /** A URL attribute's text, or `''` where its scheme is outside the safe set. */
14
21
  export function safeUrl(value: unknown): string {
package/src/ui/Router.ts CHANGED
@@ -4,6 +4,7 @@ import { ERROR_META } from '#shared/ERROR_META.ts'
4
4
  import { firstMatch, type Params, partsOf } from '#shared/matchRoute.ts'
5
5
  import { unmounted } from '#shared/mount.ts'
6
6
  import { NAVIGATION_HEADER } from '#shared/NAVIGATION_HEADER.ts'
7
+ import { OFFLINE_SAVED } from '#shared/OFFLINE_CACHES.ts'
7
8
  import type { ShareScope } from '#shared/reactive/share.ts'
8
9
  import { arrive, route } from '#shared/route.ts'
9
10
  import { rankRoutes, type Segment } from '#shared/routePattern.ts'
@@ -255,6 +256,20 @@ export class Router {
255
256
  // whole reason the ticket is checked here rather than after the mount — and
256
257
  // `navigating` stays the newer navigation's to clear.
257
258
  if (ticket.signal.aborted) return
259
+ // 30.32 — OFFLINE, THE DOCUMENT LOAD BELOW CAN ONLY PAINT THE BROWSER'S ERROR PAGE over a
260
+ // page that works. A history step has already moved the bar, so it is left to the
261
+ // document.
262
+ if (
263
+ answer === undefined &&
264
+ !navigator.onLine &&
265
+ step.direction === 'push'
266
+ ) {
267
+ // 45.12 — UNLESS THE SERVICE WORKER SAVED THE PAGE, which the document load is
268
+ // then answered with.
269
+ if ((await savedOffline(target)) && !ticket.signal.aborted)
270
+ location.href = target.href
271
+ return
272
+ }
258
273
  // 30.18 — A REFUSAL, A RESPONSE MIDDLEWARE ANSWERED ITSELF, or no answer at all: the
259
274
  // document asks again and paints whatever the server says. A fetch that failed —
260
275
  // offline, or a redirect to another origin that CORS will not let it follow — goes to
@@ -696,6 +711,16 @@ function loadOrMiss(route: Compiled): Promise<PageModule[] | undefined> {
696
711
  return load(route).catch(() => undefined)
697
712
  }
698
713
 
714
+ /** Whether a service worker controls the page and saved `target`'s page (45.12). */
715
+ async function savedOffline(target: URL): Promise<boolean> {
716
+ if (navigator.serviceWorker?.controller == null) return false
717
+ const saved = await caches.match(target.href, {
718
+ cacheName: OFFLINE_SAVED,
719
+ ignoreVary: true,
720
+ })
721
+ return saved !== undefined
722
+ }
723
+
699
724
  /**
700
725
  * Asks the server's `middleware` about an address; `undefined` where the fetch failed.
701
726
  *
@@ -0,0 +1,34 @@
1
+ // 45.1 — THE SERVICE WORKER, REGISTERED where the document carries the tag `ABIDE_OFFLINE` has the
2
+ // server write. The route table calls this in every app, so what the server says decides.
3
+
4
+ import { WORKER_ADDRESS } from '#shared/ADDRESSES.ts'
5
+ import { MOUNT, mounted } from '#shared/mount.ts'
6
+ import { OFFLINE_META } from '#shared/OFFLINE_META.ts'
7
+ import { onPrincipalRotated } from '#shared/principalRotated.ts'
8
+ import { HYDRATE_LOG } from './Mismatch.ts'
9
+
10
+ /** Registers the app's service worker once the page has loaded, over the mount's whole scope. */
11
+ export function registerWorker(): void {
12
+ if (document.querySelector(`meta[name="${OFFLINE_META}"]`) === null) return
13
+ if (!('serviceWorker' in navigator)) return
14
+ const workers = navigator.serviceWorker
15
+ const register = (): void => {
16
+ workers
17
+ .register(mounted(WORKER_ADDRESS), {
18
+ scope: `${MOUNT.current()}/`,
19
+ })
20
+ // 45.10
21
+ .catch((error: unknown) =>
22
+ HYDRATE_LOG.warning(
23
+ 'the service worker did not register',
24
+ error,
25
+ ),
26
+ )
27
+ }
28
+ // AFTER THE LOAD, so installing it — every file of the bundle fetched — does not compete with
29
+ // the page it is installing for.
30
+ if (document.readyState === 'complete') register()
31
+ else addEventListener('load', register, { once: true })
32
+ // 45.7 — a sign-in or sign-out in any tab: the pages it saved were another caller's.
33
+ onPrincipalRotated(() => workers.controller?.postMessage('rotated'), true)
34
+ }
package/src/ui/routing.ts CHANGED
@@ -15,4 +15,5 @@
15
15
  // `ROUTING_SPECIFIER`. An app with no page never resolves this entry.
16
16
 
17
17
  export { listenForRoutes } from './listenForRoutes.ts'
18
+ export { registerWorker } from './registerWorker.ts'
18
19
  export { type PageMount, type Route, Router, startRouter } from './Router.ts'
@@ -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
  }