@weft-js/start 0.1.1 → 0.1.2

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 (213) hide show
  1. package/README.md +36 -3
  2. package/crate/Cargo.lock +1347 -0
  3. package/crate/Cargo.toml +72 -0
  4. package/crate/rust-toolchain.toml +3 -0
  5. package/crate/src/build/emit.rs +103 -0
  6. package/crate/src/build.rs +367 -0
  7. package/crate/src/ctx.rs +186 -0
  8. package/crate/src/envelope.rs +163 -0
  9. package/crate/src/functions.rs +173 -0
  10. package/crate/src/lib.rs +118 -0
  11. package/crate/src/native.rs +212 -0
  12. package/crate/src/otel.rs +62 -0
  13. package/crate/src/server.rs +203 -0
  14. package/package.json +46 -37
  15. package/src/app.mjs +38 -8
  16. package/src/asset-receipt.d.mts +13 -0
  17. package/src/asset-receipt.mjs +61 -0
  18. package/src/auth/better-auth.ts +215 -17
  19. package/src/auth/drizzle.ts +1 -1
  20. package/src/auth.ts +21 -6
  21. package/src/boot.ts +19 -8
  22. package/src/bus/postgres.ts +32 -19
  23. package/src/bus.ts +7 -4
  24. package/src/{capture.mjs → capture.ts} +34 -1
  25. package/src/changes-sql.d.mts +8 -2
  26. package/src/changes-sql.mjs +26 -10
  27. package/src/changes.ts +165 -66
  28. package/src/channel/connections.ts +216 -0
  29. package/src/channel/socket.ts +396 -0
  30. package/src/channel-fake.ts +19 -4
  31. package/src/channel.ts +120 -492
  32. package/src/client.tsx +9 -2
  33. package/src/contract/describe.mjs +231 -0
  34. package/src/contract.mjs +144 -157
  35. package/src/data/cache.ts +354 -0
  36. package/src/data/definitions.ts +94 -0
  37. package/src/data/draft.ts +104 -0
  38. package/src/data/foundation.ts +95 -0
  39. package/src/data/live-api.ts +22 -0
  40. package/src/data/mutation.ts +138 -0
  41. package/src/data/primitives.ts +135 -0
  42. package/src/data/query.ts +106 -0
  43. package/src/data/transport.ts +121 -0
  44. package/src/data/types.ts +303 -0
  45. package/src/data.ts +69 -894
  46. package/src/db/bun-sqlite.d.ts +4 -1
  47. package/src/db/changes-mysql.ts +61 -21
  48. package/src/db/changes-postgres.ts +197 -118
  49. package/src/db/changes-sqlite.ts +131 -64
  50. package/src/db/core.ts +38 -13
  51. package/src/db/index.ts +20 -4
  52. package/src/db/mysql.ts +22 -6
  53. package/src/db/postgres.ts +49 -13
  54. package/src/db/sqlite.ts +53 -27
  55. package/src/derive.d.mts +4 -1
  56. package/src/derive.mjs +136 -39
  57. package/src/dev-runtime.mjs +8 -4
  58. package/src/exec/errors.ts +57 -0
  59. package/src/exec/landing.ts +66 -0
  60. package/src/exec/scope.ts +174 -0
  61. package/src/exec/touch.ts +80 -0
  62. package/src/exec/validation.ts +176 -0
  63. package/src/exec.ts +191 -341
  64. package/src/extension.ts +8 -2
  65. package/src/first-paint.ts +26 -9
  66. package/src/form/value.ts +15 -0
  67. package/src/form.tsx +132 -43
  68. package/src/hub/channels.ts +102 -0
  69. package/src/hub/connections.ts +147 -0
  70. package/src/hub/delivery.ts +62 -0
  71. package/src/hub/feed.ts +168 -0
  72. package/src/hub/presence.ts +64 -0
  73. package/src/hub/route.ts +158 -0
  74. package/src/hub/subscriptions.ts +212 -0
  75. package/src/hub/topic.ts +98 -0
  76. package/src/hub/tracked.ts +12 -0
  77. package/src/hub/types.ts +190 -0
  78. package/src/hub/view.ts +147 -0
  79. package/src/hub.ts +132 -758
  80. package/src/index.ts +84 -10
  81. package/src/inline.mjs +356 -0
  82. package/src/inspect/compare.mjs +162 -0
  83. package/src/inspect/routes.mjs +154 -0
  84. package/src/inspect/samples.mjs +59 -0
  85. package/src/inspect/url.mjs +2 -0
  86. package/src/inspect.mjs +146 -187
  87. package/src/link/election.ts +178 -0
  88. package/src/link/lines.ts +27 -0
  89. package/src/link.ts +90 -166
  90. package/src/list-drizzle.ts +104 -18
  91. package/src/list.ts +127 -36
  92. package/src/listen.ts +32 -6
  93. package/src/live/api.ts +18 -0
  94. package/src/live/backoff.ts +6 -0
  95. package/src/live/frames.ts +42 -0
  96. package/src/live/read.ts +77 -0
  97. package/src/live/sub-types.ts +24 -0
  98. package/src/live/transport.ts +86 -0
  99. package/src/live/types.ts +52 -0
  100. package/src/live-fake.ts +20 -5
  101. package/src/live-target.mjs +112 -37
  102. package/src/live.ts +116 -225
  103. package/src/media.ts +8 -1
  104. package/src/migrations.mjs +14 -6
  105. package/src/mux.ts +161 -64
  106. package/src/og/DefaultCard.tsx +40 -9
  107. package/src/og/fonts.mjs +16 -8
  108. package/src/og/images.mjs +43 -22
  109. package/src/og/index.mjs +119 -43
  110. package/src/og/native.mjs +11 -4
  111. package/src/range.mjs +15 -3
  112. package/src/router.ts +56 -6
  113. package/src/routes.mjs +73 -22
  114. package/src/run/admission.ts +80 -0
  115. package/src/run/boot.tsx +115 -0
  116. package/src/run/cache-presentation.ts +67 -0
  117. package/src/run/cache.ts +254 -0
  118. package/src/run/helpers.ts +197 -0
  119. package/src/run/listeners.ts +155 -0
  120. package/src/run/navigation.ts +382 -0
  121. package/src/run/page-skeleton.tsx +35 -0
  122. package/src/run/prefetch.ts +128 -0
  123. package/src/run/retain-view.ts +18 -0
  124. package/src/run/shape-preparation.ts +97 -0
  125. package/src/run/shape-styles.ts +72 -0
  126. package/src/run/source-context/pin.ts +124 -0
  127. package/src/run/source-context.ts +311 -0
  128. package/src/run.tsx +50 -178
  129. package/src/rust/common.ts +163 -0
  130. package/src/rust/native.ts +282 -0
  131. package/src/rust.ts +103 -229
  132. package/src/server/card.tsx +65 -0
  133. package/src/server/compression.ts +115 -0
  134. package/src/server/core.ts +70 -0
  135. package/src/server/dispatch.ts +70 -0
  136. package/src/server/form.ts +85 -0
  137. package/src/server/functions.ts +129 -0
  138. package/src/server/handler.ts +39 -0
  139. package/src/server/head.ts +96 -0
  140. package/src/server/media.ts +60 -0
  141. package/src/server/navigation-cache.ts +80 -0
  142. package/src/server/navigation.tsx +85 -0
  143. package/src/server/page-cold.ts +95 -0
  144. package/src/server/page-stream.tsx +181 -0
  145. package/src/server/page-types.ts +51 -0
  146. package/src/server/page-warm.ts +130 -0
  147. package/src/server/page.ts +69 -0
  148. package/src/server/payload.ts +89 -0
  149. package/src/server/prerender.ts +60 -0
  150. package/src/server/preview.tsx +67 -0
  151. package/src/server/render.ts +167 -0
  152. package/src/server/request.ts +98 -0
  153. package/src/server/resolver.ts +228 -0
  154. package/src/server/routes.ts +150 -0
  155. package/src/server/services.ts +69 -0
  156. package/src/server/text.ts +29 -0
  157. package/src/server/types.ts +160 -0
  158. package/src/server.tsx +82 -971
  159. package/src/shape.mjs +30 -6
  160. package/src/share/display.ts +50 -0
  161. package/src/share/network.ts +119 -0
  162. package/src/share/receive.ts +70 -0
  163. package/src/share/state.ts +30 -0
  164. package/src/share/tab-queue.ts +28 -0
  165. package/src/share-server.ts +128 -43
  166. package/src/share.ts +103 -209
  167. package/src/sheets.mjs +30 -18
  168. package/src/sidecar/build.mjs +170 -0
  169. package/src/sidecar/cargo.mjs +164 -0
  170. package/src/sidecar/common.mjs +17 -0
  171. package/src/sidecar/dev-build.mjs +91 -0
  172. package/src/sidecar/dev-watch.mjs +88 -0
  173. package/src/sidecar/manifest.mjs +19 -0
  174. package/src/sidecar/process.mjs +58 -0
  175. package/src/sidecar/refresh.mjs +19 -0
  176. package/src/sidecar.mjs +75 -416
  177. package/src/socket/check.ts +50 -0
  178. package/src/socket/deliver.ts +16 -0
  179. package/src/socket/publish.ts +56 -0
  180. package/src/socket/rooms.ts +41 -0
  181. package/src/socket/route.ts +79 -0
  182. package/src/socket/shared.ts +26 -0
  183. package/src/socket/types.ts +48 -0
  184. package/src/socket.ts +205 -194
  185. package/src/stream.ts +10 -1
  186. package/src/verify/app.ts +39 -0
  187. package/src/verify/cleanup.ts +27 -0
  188. package/src/verify/database.ts +11 -0
  189. package/src/verify/live.ts +63 -0
  190. package/src/verify/notify.ts +35 -0
  191. package/src/verify/skip.ts +19 -0
  192. package/src/verify/support.ts +149 -0
  193. package/src/verify/timing.ts +15 -0
  194. package/src/verify/trigger.ts +14 -0
  195. package/src/verify.ts +77 -176
  196. package/src/view.tsx +96 -11
  197. package/src/vite/asset-bytes.mjs +95 -0
  198. package/src/vite/assets.mjs +117 -0
  199. package/src/vite/config-hooks.mjs +166 -0
  200. package/src/vite/dev-hooks.mjs +103 -0
  201. package/src/vite/dev.mjs +102 -0
  202. package/src/vite/entries.mjs +145 -0
  203. package/src/vite/module-hooks.mjs +288 -0
  204. package/src/vite/route-modules.mjs +289 -0
  205. package/src/vite/server-functions.mjs +222 -0
  206. package/src/vite/shape-entry-css.mjs +187 -0
  207. package/src/vite/shape-override.mjs +226 -0
  208. package/src/vite/shared.mjs +51 -0
  209. package/src/vite/sheets.mjs +92 -0
  210. package/src/vite/state.mjs +89 -0
  211. package/src/vite/syntax.mjs +139 -0
  212. package/src/vite.mjs +152 -853
  213. package/src/capture.d.mts +0 -1
@@ -0,0 +1,155 @@
1
+ import { drain, wake } from '@weft-js/core/dom'
2
+ import type { Held } from '../boot'
3
+ import { client, peers } from '../router'
4
+ import { admitLink, routeClicks } from './admission'
5
+ import type { Navigation } from './navigation'
6
+
7
+ type Hot = { on(event: string, on: (data: { queries: boolean }) => unknown): void }
8
+
9
+ export function listeners(nav: Navigation, hot: () => Hot | undefined) {
10
+ addEventListener('online', nav.owing)
11
+ addEventListener('focus', nav.owing)
12
+ addEventListener('pagehide', nav.dispose)
13
+ // A page restored from the back/forward cache was disposed on the way out: let it prefetch again.
14
+ addEventListener('pageshow', (e) => e.persisted && nav.prefetch.resume())
15
+ // A cross-document transition's pagereveal and pageswap are settled from the head (page-stream.tsx `REVEAL`): the page
16
+ // reveals before this runs. One still going when it does is settled here too.
17
+ settle((document as Document & { activeViewTransition?: ViewTransition }).activeViewTransition)
18
+ if (hot()) hot()!.on('weft:refresh', (changed) => void nav.reloaded(changed))
19
+ client.invalidated = nav.invalidated
20
+ client.sessionChanged = nav.dispose
21
+ clicks(nav)
22
+ preload(nav)
23
+ history(nav)
24
+ peers?.addEventListener('message', (m) => {
25
+ if (!m.data) {
26
+ nav.dispose()
27
+ location.reload()
28
+ }
29
+ })
30
+ const q = (window as Window & { __wq?: Held }).__wq
31
+ if (q) {
32
+ q.w = wake
33
+ if (q.l) wake(q.l)
34
+ drain()
35
+ }
36
+ }
37
+
38
+ // A fade can intercept a click; replay it on the element underneath once the picture is gone.
39
+ function clicks(nav: Navigation) {
40
+ routeClicks(document, nav.routes, (href) => nav.go(href))
41
+ let ended: Promise<unknown> | undefined
42
+ document.addEventListener(
43
+ 'pointerdown',
44
+ () => {
45
+ ended = nav.fading?.finished.catch(() => {})
46
+ nav.fading?.skipTransition()
47
+ nav.fading = undefined
48
+ },
49
+ true,
50
+ )
51
+ document.addEventListener(
52
+ 'click',
53
+ (e) => {
54
+ const gone = ended
55
+ ended = undefined
56
+ if (!gone || e.target !== document.documentElement) return
57
+ void gone.then(() => {
58
+ const under = document.elementFromPoint(e.clientX, e.clientY)
59
+ if (under !== document.documentElement) under?.dispatchEvent(new MouseEvent('click', e))
60
+ })
61
+ },
62
+ true,
63
+ )
64
+ }
65
+
66
+ // Only known-cost intent can start a bounded data request; no page module runs here.
67
+ export function preload(nav: Navigation) {
68
+ let pending: ReturnType<typeof setTimeout> | undefined
69
+ const network = (
70
+ navigator as Navigator & {
71
+ connection?: {
72
+ saveData?: boolean
73
+ effectiveType?: string
74
+ addEventListener?: (type: string, fn: () => void) => void
75
+ removeEventListener?: (type: string, fn: () => void) => void
76
+ }
77
+ }
78
+ ).connection
79
+ const cancel = () => {
80
+ clearTimeout(pending)
81
+ nav.prefetch.cancel()
82
+ }
83
+ const intent = (e: Event) => {
84
+ clearTimeout(pending)
85
+ const target = e.target as Element | null
86
+ const admitted = admitLink(e, nav.routes)
87
+ if (
88
+ !admitted ||
89
+ admitted.noop ||
90
+ !target ||
91
+ target.closest('[data-preload="off"]') ||
92
+ target.closest('input,textarea,select,[contenteditable]:not([contenteditable="false"])')
93
+ )
94
+ return
95
+ pending = setTimeout(
96
+ () => {
97
+ if (document.hidden) return
98
+ const version = nav.cache.version
99
+ nav.cache.setBuild(nav.routes.build)
100
+ if (version !== nav.cache.version) nav.prefetch.cancel(true)
101
+ nav.prefetch.start(
102
+ admitted.url,
103
+ e.type === 'focusin' ? 'focus' : 'hover',
104
+ network,
105
+ navigator.onLine,
106
+ )
107
+ },
108
+ e.type === 'focusin' ? 0 : 80,
109
+ )
110
+ }
111
+ document.addEventListener('pointerover', intent)
112
+ document.addEventListener('focusin', intent)
113
+ document.addEventListener('pointerout', cancel)
114
+ document.addEventListener('focusout', cancel)
115
+ const visibility = () => {
116
+ if (document.hidden) cancel()
117
+ }
118
+ document.addEventListener('visibilitychange', visibility)
119
+ addEventListener('offline', cancel)
120
+ addEventListener('pagehide', cancel)
121
+ network?.addEventListener?.('change', cancel)
122
+ return () => {
123
+ cancel()
124
+ document.removeEventListener('pointerover', intent)
125
+ document.removeEventListener('focusin', intent)
126
+ document.removeEventListener('pointerout', cancel)
127
+ document.removeEventListener('focusout', cancel)
128
+ document.removeEventListener('visibilitychange', visibility)
129
+ removeEventListener('offline', cancel)
130
+ removeEventListener('pagehide', cancel)
131
+ network?.removeEventListener?.('change', cancel)
132
+ }
133
+ }
134
+
135
+ // An in-page anchor fires popstate too; only a changed path needs a navigation.
136
+ function history(nav: Navigation) {
137
+ nav.at = location.pathname + location.search
138
+ addEventListener('popstate', () => {
139
+ if (location.pathname + location.search !== nav.at)
140
+ return void nav.go(location.href, { pop: true })
141
+ if (nav.current) {
142
+ nav.current.abort()
143
+ nav.settle()
144
+ }
145
+ })
146
+ }
147
+
148
+ /** Mark a transition's outcome as seen: a skipped one rejects all three of its promises. */
149
+ function settle(t: ViewTransition | null | undefined) {
150
+ if (!t) return
151
+ const seen = () => {}
152
+ t.ready.catch(seen)
153
+ t.finished.catch(seen)
154
+ t.updateCallbackDone.catch(seen)
155
+ }
@@ -0,0 +1,382 @@
1
+ import { flush } from '@weft-js/core'
2
+ import { setHead } from '@weft-js/core/head'
3
+ import {
4
+ applyMatches,
5
+ client,
6
+ type Level,
7
+ type NavigateOptions,
8
+ type RoutePayload,
9
+ } from '../router'
10
+ import type { ClientRoutes } from '../run'
11
+ import { streamed, type mount } from './boot'
12
+ import { matchingRouteAssets } from '../asset-receipt.mjs'
13
+ import { ask, changedRouteParams, crossfade, current_, guess, revive } from './helpers'
14
+ import { presentShape, type PreparedRouteShape } from './shape-preparation'
15
+ import { retainSourceContext, type SourceContext } from './source-context'
16
+ import { RouteCache, type Read } from './cache'
17
+ import { Prefetch } from './prefetch'
18
+ import { CachePresentation, retryDelay } from './cache-presentation'
19
+
20
+ const RETRY = 1000,
21
+ RETRIES = 30_000
22
+
23
+ type Options = NavigateOptions & { pop?: boolean; hops?: number }
24
+ type Page = ReturnType<typeof mount>
25
+
26
+ export class Navigation {
27
+ route: Page['route']
28
+ chunk: Page['chunk']
29
+ island: Page['island']
30
+ setBag: Page['setBag']
31
+ ready: Page['ready']
32
+ tags: Page['initialTags']
33
+ current: AbortController | undefined
34
+ refreshing: { ctrl: AbortController; tags: string[] } | undefined
35
+ /** Tags invalidated while a navigation was out: its route may read them, and that's only known when it lands. */
36
+ owed: string[] = []
37
+ again: ReturnType<typeof setTimeout> | undefined
38
+ tries = 0
39
+ prefetch: Prefetch
40
+ cache: RouteCache
41
+ presentation = new CachePresentation()
42
+ // A fade pictures the whole page twice, and the picture holds up every frame. Where that's slow, the page just changes.
43
+ fades = !(navigator.hardwareConcurrency <= 4)
44
+ fading: ViewTransition | undefined
45
+ painted = false
46
+ at!: string
47
+ shape: { outlet: Level; leases: PreparedRouteShape[] } | undefined
48
+ source: SourceContext | undefined
49
+ committedURL = location.href
50
+ committedBase = document.baseURI
51
+
52
+ constructor(
53
+ readonly routes: ClientRoutes,
54
+ page: Page,
55
+ ) {
56
+ this.cache = new RouteCache(routes.build)
57
+ this.prefetch = new Prefetch(this.cache, this.now, () => !!client.seed)
58
+ this.ready = page.ready
59
+ this.route = page.route
60
+ this.chunk = page.chunk
61
+ this.island = page.island
62
+ this.setBag = page.setBag
63
+ this.tags = page.initialTags
64
+ // Two frames in, the page has painted; a transition before then has no old picture to hold.
65
+ requestAnimationFrame(() => requestAnimationFrame(() => (this.painted = true)))
66
+ }
67
+
68
+ now = () => client.now?.() ?? 0
69
+ needs = (next: RoutePayload) =>
70
+ Promise.all([...next.m.map((m) => this.chunk(m.id)), ...(next.i ?? []).map(this.island)])
71
+ reads = (t: string[]) => this.tags.some((l) => l.some((x) => t.includes(x)))
72
+
73
+ /** The page moved on: drop old chunks and build held Boundaries from this data. */
74
+ release() {
75
+ const h = streamed.$wh || {}
76
+ for (const k of Object.keys(h)) {
77
+ const build = h[k]!
78
+ h[k] = () => {}
79
+ build()
80
+ }
81
+ }
82
+
83
+ apply(next: RoutePayload, url: URL, path: string, since: number, scroll: boolean) {
84
+ const owner = this.current
85
+ const current = () => this.current === owner && !owner?.signal.aborted
86
+ return () => {
87
+ if (!current()) return
88
+ this.at = path
89
+ if (next.c) client.seed ? client.seed(next.c, since) : (client.seeds ??= []).push(...next.c)
90
+ if (!current()) return
91
+ this.tags = next.m.map((m) => m.t ?? [])
92
+ this.route.assetBytes = matchingRouteAssets(next.a, this.routes.build)
93
+ this.setBag(next.b)
94
+ if (!current()) return
95
+ const source = this.source
96
+ this.source = undefined
97
+ source?.stop()
98
+ const old = this.detachShape()
99
+ const previousURL = this.committedURL
100
+ const previousBase = this.committedBase
101
+ this.committedURL = url.href
102
+ this.committedBase = document.baseURI
103
+ try {
104
+ applyMatches(this.route, next.m.map(revive), path, next.s)
105
+ if (!current()) return
106
+ if (next.m.some((m) => m.s)) current_(url.pathname)
107
+ if (!current()) return
108
+ if (next.h) setHead(next.h, url.pathname)
109
+ if (!current()) return
110
+ this.release()
111
+ if (!current()) return
112
+ flush()
113
+ } catch (error) {
114
+ if (current()) {
115
+ this.committedURL = previousURL
116
+ this.committedBase = previousBase
117
+ }
118
+ throw error
119
+ } finally {
120
+ old?.forEach((lease) => lease.release())
121
+ source?.release()
122
+ }
123
+ if (!current()) return
124
+ if (scroll) {
125
+ const target = url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1)))
126
+ target ? target.scrollIntoView() : window.scrollTo(0, 0)
127
+ }
128
+ }
129
+ }
130
+
131
+ /** Find the first layout containing a changed parameter, or the leaf for a changed query. */
132
+ changedParams(ids: string[], url: URL) {
133
+ return changedRouteParams(this.routes, this.committedURL, ids, url)
134
+ }
135
+
136
+ /** Prepare all changed levels before replacing the current outlet. */
137
+ present(ids: string[], url: URL, force = false) {
138
+ return presentShape(this, ids, url, force)
139
+ }
140
+
141
+ detachShape() {
142
+ const shape = this.shape
143
+ if (!shape) return
144
+ this.shape = undefined
145
+ shape.outlet.shape.value = undefined
146
+ return shape.leases
147
+ }
148
+
149
+ clearShape() {
150
+ const leases = this.detachShape()
151
+ if (leases) {
152
+ try {
153
+ flush()
154
+ } finally {
155
+ leases.forEach((lease) => lease.release())
156
+ }
157
+ }
158
+ }
159
+
160
+ /** No navigation is out any more: what it left owed is for the page now showing. */
161
+ settle() {
162
+ // Every way a navigation ends comes through here, so its cached-presentation timer stops here too.
163
+ if (this.current) this.presentation.stop(this.current)
164
+ this.clearShape()
165
+ if (this.source && this.current?.signal.aborted) {
166
+ history.replaceState(history.state, '', this.committedURL)
167
+ const url = new URL(this.committedURL)
168
+ this.at = url.pathname + url.search
169
+ this.route.path.value = this.at
170
+ current_(url.pathname)
171
+ flush()
172
+ }
173
+ this.source?.release()
174
+ this.source = undefined
175
+ this.current = undefined
176
+ const t = this.owed
177
+ this.owed = []
178
+ if (this.reads(t)) void this.refresh(t)
179
+ }
180
+
181
+ dispose = () => {
182
+ this.cache.clear()
183
+ this.prefetch.stop()
184
+ if (this.current) this.presentation.stop(this.current)
185
+ this.current?.abort()
186
+ this.current = undefined
187
+ this.refreshing?.ctrl.abort()
188
+ this.refreshing = undefined
189
+ clearTimeout(this.again)
190
+ this.owed = []
191
+ this.clearShape()
192
+ this.source?.release()
193
+ this.source = undefined
194
+ }
195
+
196
+ go = async (href: string, opts: Options = {}) => {
197
+ const url = new URL(href, location.href)
198
+ if (url.origin !== location.origin) return location.assign(url.href)
199
+ if (!opts.pop && url.href === location.href) return
200
+ const buildVersion = this.cache.version
201
+ this.cache.setBuild(this.routes.build)
202
+ if (buildVersion !== this.cache.version) this.prefetch.cancel(true)
203
+ if (this.current) this.presentation.stop(this.current)
204
+ this.current?.abort()
205
+ if (this.refreshing) {
206
+ this.refreshing.ctrl.abort()
207
+ this.owed.push(...this.refreshing.tags)
208
+ this.refreshing = undefined
209
+ }
210
+ const ctrl = (this.current = new AbortController())
211
+ const path = url.pathname + url.search
212
+ try {
213
+ this.source ??=
214
+ retainSourceContext(document, this.committedURL, this.committedBase) ?? undefined
215
+ if (!this.source) return opts.pop ? location.replace(url.href) : location.assign(url.href)
216
+ if (!opts.pop && url.href !== location.href)
217
+ history[opts.replace ? 'replaceState' : 'pushState'](null, '', url.href)
218
+ this.at = path
219
+ this.route.path.value = path
220
+ current_(url.pathname)
221
+ flush()
222
+ const speculative = this.prefetch.claim(url, ctrl.signal)
223
+ this.prefetch.cancel()
224
+ const cached = opts.pop ? this.cache.peek(url, !!client.seed) : undefined
225
+ const shown = this.presentation.show(this, ctrl, url, cached)
226
+ if (ctrl.signal.aborted || this.current !== ctrl) return
227
+ if (!shown) this.present(guess(this.routes, url.pathname), url)
228
+ await this.travel(url, ctrl, opts, shown, speculative)
229
+ } finally {
230
+ this.presentation.stop(ctrl)
231
+ if (this.current === ctrl) this.settle()
232
+ }
233
+ }
234
+
235
+ async travel(
236
+ url: URL,
237
+ ctrl: AbortController,
238
+ opts: Options,
239
+ shown = false,
240
+ speculative?: Promise<Read | undefined>,
241
+ ) {
242
+ const path = url.pathname + url.search
243
+ for (const id of guess(this.routes, url.pathname)) void this.chunk(id).catch(() => {})
244
+ let next: RoutePayload
245
+ let since: number
246
+ let version: number
247
+ let retries = 0
248
+ for (;;) {
249
+ try {
250
+ const shared = speculative ? await speculative : undefined
251
+ speculative = undefined
252
+ const read =
253
+ shared && (!shared.next.c?.length || client.seed)
254
+ ? shared
255
+ : await this.cache.load({
256
+ url,
257
+ signal: ctrl.signal,
258
+ fetchRoute: ask,
259
+ now: this.now,
260
+ canSeed: () => !!client.seed,
261
+ onForget: shown ? () => this.presentation.check(this) : undefined,
262
+ })
263
+ this.cache.setBuild(this.routes.build)
264
+ if (ctrl.signal.aborted) return
265
+ if (read.version !== this.cache.version) continue
266
+ next = read.next
267
+ if (next.s === 401 || next.s === 403) this.prefetch.cancel(true)
268
+ since = read.since
269
+ version = read.version
270
+ if (next.r) {
271
+ const hops = (opts.hops ?? 0) + 1
272
+ return hops > 5 ? location.replace(next.r) : this.go(next.r, { replace: true, hops })
273
+ }
274
+ if (shown && (next.s === 401 || next.s === 403)) {
275
+ if (this.ready?.(next)) {
276
+ this.apply(next, url, path, since, false)()
277
+ return
278
+ }
279
+ this.presentation.check(this)
280
+ if (ctrl.signal.aborted) return
281
+ }
282
+ await this.needs(next)
283
+ this.cache.setBuild(this.routes.build)
284
+ if (ctrl.signal.aborted) return
285
+ if (read.version !== this.cache.version) continue
286
+ if (shown && read.notModified) return
287
+ break
288
+ } catch {
289
+ if (ctrl.signal.aborted) return
290
+ if (!shown) return location.replace(url.href)
291
+ if (!this.cache.peek(url, !!client.seed)) {
292
+ this.current = undefined
293
+ ctrl.abort()
294
+ return location.replace(url.href)
295
+ }
296
+ await retryDelay(ctrl.signal, Math.min(RETRIES, RETRY * 2 ** retries++))
297
+ continue
298
+ }
299
+ }
300
+ let swapped = false
301
+ const swap = () => {
302
+ swapped = true
303
+ this.cache.setBuild(this.routes.build)
304
+ if (ctrl.signal.aborted || this.current !== ctrl || this.cache.version !== version) return
305
+ this.apply(next, url, path, since, opts.scroll !== false && !opts.pop)()
306
+ }
307
+ await crossfade(this, swap, () => swapped)
308
+ if (!ctrl.signal.aborted && this.current === ctrl && this.cache.version !== version)
309
+ await this.travel(url, ctrl, opts, shown)
310
+ }
311
+
312
+ /** Fetch the page's data again in place, retrying a failed refresh. */
313
+ async refresh(t: string[]) {
314
+ t = [...new Set([...t, ...(this.refreshing?.tags ?? [])])]
315
+ this.refreshing?.ctrl.abort()
316
+ const self = (this.refreshing = { ctrl: new AbortController(), tags: t })
317
+ const url = new URL(location.href),
318
+ since = this.now()
319
+ try {
320
+ const next: RoutePayload = await ask(url, self.ctrl.signal).then((r) => r.json())
321
+ if (self.ctrl.signal.aborted) return
322
+ if (next.s === 401 || next.s === 403) {
323
+ this.cache.clear()
324
+ this.prefetch.cancel(true)
325
+ }
326
+ if (next.r && !self.ctrl.signal.aborted) {
327
+ this.refreshing = undefined
328
+ return void this.go(next.r, { replace: true })
329
+ }
330
+ await this.needs(next)
331
+ if (self.ctrl.signal.aborted) return
332
+ this.apply(next, url, url.pathname + url.search, since, false)()
333
+ this.tries = 0
334
+ } catch {
335
+ if (this.refreshing === self) {
336
+ this.owed.push(...t)
337
+ clearTimeout(this.again)
338
+ this.again = setTimeout(this.owing, Math.min(RETRIES, RETRY * 2 ** this.tries++))
339
+ }
340
+ }
341
+ if (this.refreshing === self) this.refreshing = undefined
342
+ }
343
+
344
+ owing = () => {
345
+ if (!this.current && !this.refreshing) this.settle()
346
+ }
347
+
348
+ reloaded = async ({ queries }: { queries: boolean }) => {
349
+ const owner = this.current
350
+ this.cache.clear()
351
+ this.prefetch.cancel(true)
352
+ const url = new URL(location.href),
353
+ since = this.now()
354
+ let version = this.cache.version
355
+ const res = await ask(url)
356
+ if (version !== this.cache.version || !res.ok) return
357
+ const next: RoutePayload = await res.clone().json()
358
+ this.cache.setBuild(this.routes.build)
359
+ if (version !== this.cache.version || next.s >= 500) return
360
+ if (next.s === 401 || next.s === 403) {
361
+ this.cache.clear()
362
+ version = this.cache.version
363
+ }
364
+ if (queries) client.refreshed?.()
365
+ this.cache.setBuild(this.routes.build)
366
+ if (version !== this.cache.version || this.current !== owner) return
367
+ if (next.r) return await this.go(next.r, { replace: true })
368
+ if (this.current) return await this.go(url.href, { pop: true })
369
+ await this.needs(next)
370
+ this.cache.setBuild(this.routes.build)
371
+ if (version !== this.cache.version || this.current !== owner) return
372
+ this.apply(next, url, url.pathname + url.search, since, false)()
373
+ }
374
+
375
+ invalidated = (t: string[]) => {
376
+ this.cache.invalidate(t)
377
+ this.presentation.check(this)
378
+ this.prefetch.cancel(true)
379
+ if (this.current) this.owed.push(...t)
380
+ else if (this.reads(t)) void this.refresh(t)
381
+ }
382
+ }
@@ -0,0 +1,35 @@
1
+ // What navigation shows for a level whose generated shape has no structure of its own (`$weftShapeThin`): a route
2
+ // whose markup is one component the compiler can't see into. A page gets a page's outline, a layout stands aside for
3
+ // its page's. A route's own `export const skeleton` is what fits exactly; this keeps the outlet from going blank.
4
+
5
+ /** One placeholder bar. The colour is the fallback without Warp; Warp's skeleton styles paint and shimmer it. */
6
+ const bar = (height: string, width: string, gap = '0.75em') =>
7
+ `display:block;block-size:${height};inline-size:${width};margin-block-end:${gap};border-radius:0.375em;background:color-mix(in srgb,currentColor 9%,transparent)`
8
+
9
+ /** A heading, a lede and a block of text, then a panel: the outline most pages share. */
10
+ export function PageSkeleton() {
11
+ return (
12
+ <div
13
+ data-scope="skeleton-scope"
14
+ aria-busy="true"
15
+ role="status"
16
+ aria-label="Loading"
17
+ inert
18
+ data-weft-page-skeleton=""
19
+ style="display:block;min-inline-size:0;padding-block:clamp(1.5rem,4vw,2.5rem)"
20
+ >
21
+ <span data-skeleton style={bar('2.25em', 'min(22ch,60%)', '1.25em')}></span>
22
+ <span data-skeleton style={bar('1em', 'min(60ch,92%)')}></span>
23
+ <span data-skeleton style={bar('1em', 'min(48ch,74%)', '2em')}></span>
24
+ <span data-skeleton style={bar('0.9em', '100%')}></span>
25
+ <span data-skeleton style={bar('0.9em', '96%')}></span>
26
+ <span data-skeleton style={bar('0.9em', '88%', '2em')}></span>
27
+ <span data-skeleton style={bar('14em', '100%', '0')}></span>
28
+ </div>
29
+ )
30
+ }
31
+
32
+ /** A layout with nothing to show of its own: its page's skeleton, in place. */
33
+ export function PassThroughSkeleton(props: { children?: unknown }) {
34
+ return <>{props.children}</>
35
+ }
@@ -0,0 +1,128 @@
1
+ import type { RouteCache, Read } from './cache'
2
+ import { ask } from './helpers'
3
+
4
+ type Intent = 'hover' | 'focus'
5
+ type Network = { saveData?: boolean; effectiveType?: string }
6
+ type Job = { ctrl: AbortController; promise: Promise<Read | undefined>; promoted: boolean }
7
+
8
+ /** Buffer at most the reservation, canceling the response stream when it is exceeded. */
9
+ async function limited(response: Response, limit: number, signal: AbortSignal): Promise<Response> {
10
+ if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
11
+ if (!response.body) return response
12
+ const reader = response.body.getReader(),
13
+ chunks: Uint8Array[] = []
14
+ let bytes = 0
15
+ const abort = () => {
16
+ void reader.cancel().catch(() => {})
17
+ }
18
+ signal.addEventListener('abort', abort, { once: true })
19
+ try {
20
+ for (;;) {
21
+ const item = await reader.read()
22
+ if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
23
+ if (item.done) break
24
+ bytes += item.value.byteLength
25
+ if (bytes > limit) {
26
+ abort()
27
+ throw new Error('Prefetch reservation exceeded')
28
+ }
29
+ chunks.push(item.value)
30
+ }
31
+ } finally {
32
+ signal.removeEventListener('abort', abort)
33
+ reader.releaseLock()
34
+ }
35
+ const body = new Uint8Array(bytes)
36
+ let offset = 0
37
+ for (const chunk of chunks) {
38
+ body.set(chunk, offset)
39
+ offset += chunk.byteLength
40
+ }
41
+ return new Response(body, {
42
+ status: response.status,
43
+ statusText: response.statusText,
44
+ headers: response.headers,
45
+ })
46
+ }
47
+
48
+ /** Admit and deduplicate bounded route-data speculation without evaluating route modules. */
49
+ export class Prefetch {
50
+ private jobs = new Map<string, Job>()
51
+ private stopped = false
52
+ private used: { at: number; bytes: number }[] = []
53
+
54
+ constructor(
55
+ private cache: RouteCache,
56
+ private now: () => number,
57
+ private canSeed: () => boolean,
58
+ ) {}
59
+
60
+ get size() {
61
+ return this.jobs.size
62
+ }
63
+ private key(url: URL) {
64
+ return url.pathname + url.search
65
+ }
66
+
67
+ start(url: URL, intent: Intent, network: Network | undefined, online: boolean) {
68
+ const key = this.key(url)
69
+ if (this.stopped || this.jobs.has(key)) return
70
+ if (!online || network?.saveData) return
71
+ const kind = network?.effectiveType
72
+ const fast = kind === '4g',
73
+ slow = kind === '2g' || kind === 'slow-2g'
74
+ if (!fast && intent === 'hover') return
75
+ const cap = (fast ? 64 : slow ? 8 : 16) * 1024
76
+ const allowance = (fast ? 256 : slow ? 16 : 32) * 1024
77
+ const cost = this.cache.estimate(url)
78
+ if (!cost || cost.total > cap || this.jobs.size >= (fast ? 2 : 1)) return
79
+ const now = performance.now()
80
+ this.used = this.used.filter((entry) => now - entry.at < 30_000)
81
+ if (this.used.reduce((sum, entry) => sum + entry.bytes, 0) + cost.total > allowance) return
82
+ this.used.push({ at: now, bytes: cost.total })
83
+ const ctrl = new AbortController()
84
+ const job: Job = { ctrl, promoted: false, promise: Promise.resolve(undefined) }
85
+ this.jobs.set(key, job)
86
+ const version = this.cache.version
87
+ const fetchRoute = async (target: URL, signal: AbortSignal, tag?: string) => {
88
+ if (version !== this.cache.version) throw new DOMException('Aborted', 'AbortError')
89
+ return limited(await ask(target, signal, tag), cost.data, signal)
90
+ }
91
+ job.promise = this.cache
92
+ .read(url, ctrl.signal, fetchRoute, this.now, this.canSeed)
93
+ .then((result) =>
94
+ ctrl.signal.aborted || version !== this.cache.version ? undefined : result,
95
+ )
96
+ .catch(() => undefined)
97
+ .finally(() => {
98
+ if (this.jobs.get(key) === job) this.jobs.delete(key)
99
+ })
100
+ }
101
+
102
+ /** Promote an in-flight speculative request to demand without a second fetch. */
103
+ claim(url: URL, signal: AbortSignal): Promise<Read | undefined> | undefined {
104
+ const job = this.jobs.get(this.key(url))
105
+ if (!job || job.ctrl.signal.aborted) return undefined
106
+ job.promoted = true
107
+ const abort = () => job.ctrl.abort()
108
+ if (signal.aborted) abort()
109
+ else signal.addEventListener('abort', abort, { once: true })
110
+ return job.promise.finally(() => signal.removeEventListener('abort', abort))
111
+ }
112
+
113
+ /** Stop this owner's pending speculation and prevent further scheduling. */
114
+ stop() {
115
+ this.stopped = true
116
+ this.cancel(true)
117
+ }
118
+
119
+ /** Schedule again after stop(): the page came back from the back/forward cache. */
120
+ resume() {
121
+ this.stopped = false
122
+ }
123
+
124
+ /** Cancel pending speculation, optionally including work promoted to demand. */
125
+ cancel(all = false) {
126
+ for (const job of this.jobs.values()) if (all || !job.promoted) job.ctrl.abort()
127
+ }
128
+ }