@weft-js/start 0.1.0 → 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 +51 -10
  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 +124 -490
  32. package/src/client.tsx +9 -2
  33. package/src/contract/describe.mjs +231 -0
  34. package/src/contract.mjs +146 -155
  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 +97 -168
  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 +113 -35
  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 -174
  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 -964
  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,115 @@
1
+ import { enter, hydrate, hydration, level as staticLevel } from '@weft-js/core/dom'
2
+ import { client, createRouteState, type Component, type RoutePayload } from '../router'
3
+ import { App } from '../view'
4
+ import { ErrorPage, NotFoundPage } from '../builtin'
5
+ import { matchingRouteAssets } from '../asset-receipt.mjs'
6
+ import type { ClientRoutes } from '../run'
7
+ import { deferred, routeCodeReady } from './helpers'
8
+
9
+ /** What the stream's scripts leave on `globalThis`: each chunk's data as it lands, and what a held Boundary builds with. */
10
+ export const streamed = globalThis as typeof globalThis & {
11
+ $wc?: { r?: object; b?: object; s?: object }[]
12
+ $wh?: Record<string, () => void> | 0
13
+ }
14
+
15
+ export function prepare(routes: ClientRoutes, options: { container?: Element }) {
16
+ const container = options.container ?? document.getElementById('app')!
17
+ const page: RoutePayload = JSON.parse(document.getElementById('weft-route')!.textContent)
18
+ const data = JSON.parse(document.getElementById('weft-data')?.textContent ?? 'null')
19
+ const tags = page.m.map((m) => m.t ?? [])
20
+ const comps: Record<string, Component> = {}
21
+ // The built-in 404 and error pages stand in only when the app has no route file for them.
22
+ const builtin: Record<string, Component> = { 404: NotFoundPage, error: ErrorPage }
23
+ const chunk = (id: string) =>
24
+ comps[id]
25
+ ? Promise.resolve()
26
+ : routes.load[id]
27
+ ? routes.load[id]().then((m) => {
28
+ if (m.default) comps[id] = m.default
29
+ })
30
+ : Promise.resolve(builtin[id] && (comps[id] = builtin[id]))
31
+ const islands: Record<string, Component> = {}
32
+ const island = (id: string) =>
33
+ islands[id]
34
+ ? undefined
35
+ : routes.islands![id]!().then((m) => {
36
+ islands[id] = m.default
37
+ })
38
+ const loads = [
39
+ ...page.m.filter((m) => !m.s).map((m) => chunk(m.id)),
40
+ ...(page.i ?? []).map(island),
41
+ ] as Promise<void>[]
42
+ return { container, page, data, tags, comps, chunk, islands, island, loads, build: routes.build }
43
+ }
44
+
45
+ export function mount({
46
+ container,
47
+ page,
48
+ data,
49
+ tags,
50
+ comps,
51
+ chunk,
52
+ islands,
53
+ island,
54
+ build,
55
+ }: ReturnType<typeof prepare>) {
56
+ page.m.forEach((m, i) => {
57
+ for (const k of m.d ?? [])
58
+ if (!m.s) (m.data as Record<string, unknown>)[k] = deferred(page.p, i, k)
59
+ })
60
+ const route = createRouteState(page, comps)
61
+ route.assetBytes = matchingRouteAssets(page.a, build)
62
+ let bag: RoutePayload['b']
63
+ route.mount = (html, depth, next) =>
64
+ staticLevel(
65
+ html,
66
+ depth,
67
+ next,
68
+ (id) => islands[id]! as never,
69
+ bag ?? undefined,
70
+ (id) => Promise.resolve(island(id)),
71
+ )
72
+ route.around = (_d, _id, body) => enter(body)
73
+ // What landed while the chunks loaded: from here on, a Boundary that's still out is held, and its chunk builds it.
74
+ if (data)
75
+ for (const l of streamed.$wc ?? []) {
76
+ Object.assign(data.r, l.r)
77
+ Object.assign(data.b, l.b)
78
+ if (l.s) Object.assign((data.s ??= {}), l.s)
79
+ }
80
+ // What a Boundary still out claims its chunk with, once it lands: a claim like this one, of nodes that came after it.
81
+ if (data?.w)
82
+ data.w = (root: Element, fn: () => void) => {
83
+ for (const el of root.querySelectorAll('[wk]'))
84
+ hydration.registry.set(el.getAttribute('wk')!, el)
85
+ try {
86
+ fn()
87
+ } finally {
88
+ hydration.registry.clear()
89
+ const list = hydration.trash.splice(0)
90
+ setTimeout(() => {
91
+ for (const n of list) n.nodeType === 1 ? (n as Element).removeAttribute('wk') : n.remove()
92
+ })
93
+ }
94
+ }
95
+ hydrate(() => <App route={route} />, container, { data })
96
+ client.route = route
97
+ // The stream broke or failed before its last script: what's held takes its default, not a skeleton for good.
98
+ if (data?.w) {
99
+ const over = () => {
100
+ const h = streamed.$wh || {}
101
+ streamed.$wh = 0
102
+ for (const k in h) h[k]!()
103
+ }
104
+ document.readyState === 'complete' ? over() : addEventListener('load', over, { once: true })
105
+ }
106
+
107
+ return {
108
+ route,
109
+ chunk,
110
+ island,
111
+ ready: (next: RoutePayload) => routeCodeReady(next, comps, islands),
112
+ initialTags: tags,
113
+ setBag: (next: RoutePayload['b']) => (bag = next),
114
+ }
115
+ }
@@ -0,0 +1,67 @@
1
+ import { client } from '../router'
2
+ import { guess } from './helpers'
3
+ import type { RouteCache } from './cache'
4
+ import type { Navigation } from './navigation'
5
+
6
+ type Cached = NonNullable<ReturnType<RouteCache['peek']>>
7
+
8
+ /** Own the one provisional history presentation and its expiry timer. */
9
+ export class CachePresentation {
10
+ private pending:
11
+ | { ctrl: AbortController; url: URL; timer: ReturnType<typeof setTimeout> }
12
+ | undefined
13
+
14
+ show(nav: Navigation, ctrl: AbortController, url: URL, cached: Cached | undefined) {
15
+ if (!cached || !nav.ready?.(cached.next)) return false
16
+ nav.cache.setBuild(nav.routes.build)
17
+ if (cached.version !== nav.cache.version || ctrl.signal.aborted || nav.current !== ctrl)
18
+ return false
19
+ nav.apply(cached.next, url, url.pathname + url.search, cached.since, false)()
20
+ nav.cache.setBuild(nav.routes.build)
21
+ if (ctrl.signal.aborted || nav.current !== ctrl) return false
22
+ if (this.pending) clearTimeout(this.pending.timer)
23
+ const timer = setTimeout(() => this.check(nav), Math.max(0, cached.until! - performance.now()))
24
+ this.pending = { ctrl, url, timer }
25
+ this.check(nav)
26
+ return true
27
+ }
28
+
29
+ check(nav: Navigation) {
30
+ const pending = this.pending
31
+ if (!pending || nav.current !== pending.ctrl || pending.ctrl.signal.aborted) return
32
+ const cached = nav.cache.peek(pending.url, !!client.seed)
33
+ if (cached) {
34
+ clearTimeout(pending.timer)
35
+ pending.timer = setTimeout(
36
+ () => this.check(nav),
37
+ Math.max(1, Math.ceil(cached.until! - performance.now())),
38
+ )
39
+ return
40
+ }
41
+ if (!nav.present(guess(nav.routes, pending.url.pathname), pending.url, true)) {
42
+ nav.current = undefined
43
+ pending.ctrl.abort()
44
+ location.replace(pending.url.href)
45
+ }
46
+ }
47
+
48
+ stop(ctrl: AbortController) {
49
+ if (this.pending?.ctrl !== ctrl) return
50
+ clearTimeout(this.pending.timer)
51
+ this.pending = undefined
52
+ }
53
+ }
54
+
55
+ /** Wait for one retry, or stop promptly when a newer navigation cancels it. */
56
+ export function retryDelay(signal: AbortSignal, ms: number): Promise<void> {
57
+ return new Promise((resolve) => {
58
+ const finish = () => {
59
+ clearTimeout(timer)
60
+ signal.removeEventListener('abort', finish)
61
+ resolve()
62
+ }
63
+ const timer = setTimeout(finish, ms)
64
+ if (signal.aborted) finish()
65
+ else signal.addEventListener('abort', finish, { once: true })
66
+ })
67
+ }
@@ -0,0 +1,254 @@
1
+ import type { RoutePayload } from '../router'
2
+
3
+ const LIMIT = 32
4
+ const BYTES = 4 * 1024 * 1024
5
+
6
+ type Entry = {
7
+ body: string
8
+ bytes: number
9
+ tag: string
10
+ tags: string[]
11
+ since: number
12
+ until: number
13
+ }
14
+ export type Read = {
15
+ next: RoutePayload
16
+ since: number
17
+ version: number
18
+ notModified?: boolean
19
+ until?: number
20
+ }
21
+ type FetchRoute = (url: URL, signal: AbortSignal, tag?: string) => Promise<Response>
22
+
23
+ /** Bounded route bodies for conditional validation; never serves unvalidated content. */
24
+ export class RouteCache {
25
+ private entries = new Map<string, Entry>()
26
+ private bytes = 0
27
+ private epoch = 0
28
+ version = 0
29
+
30
+ constructor(private build: string | null | undefined) {}
31
+
32
+ /** Drop old validators when the client build identity changes. */
33
+ setBuild(build: string | null | undefined) {
34
+ if (this.build === build) return
35
+ this.clear()
36
+ this.build = build
37
+ }
38
+
39
+ private key(url: URL) {
40
+ return this.build
41
+ ? JSON.stringify([this.build, this.epoch, url.pathname + url.search])
42
+ : undefined
43
+ }
44
+
45
+ private remove(key: string) {
46
+ const old = this.entries.get(key)
47
+ if (old) this.bytes -= old.bytes
48
+ this.entries.delete(key)
49
+ }
50
+
51
+ /** Forget affected loader data and invalidate every request already in flight. */
52
+ invalidate(tags: string[]) {
53
+ this.version++
54
+ for (const [key, value] of this.entries)
55
+ if (value.tags.some((tag) => tags.includes(tag))) this.remove(key)
56
+ }
57
+
58
+ /** Drop all build/session-dependent bodies and validators. */
59
+ clear() {
60
+ this.version++
61
+ this.epoch++
62
+ this.entries.clear()
63
+ this.bytes = 0
64
+ }
65
+
66
+ private allowed(response: Response) {
67
+ const policy = response.headers.get('cache-control') ?? ''
68
+ const directives = policy.split(',').map((part) => part.trim().toLowerCase())
69
+ const vary = (response.headers.get('vary') ?? '')
70
+ .split(',')
71
+ .map((part) => part.trim().toLowerCase())
72
+ return (
73
+ directives.includes('private') &&
74
+ !directives.some((part) => /^no-store(?:\s*=|$)/.test(part)) &&
75
+ !response.headers.has('set-cookie') &&
76
+ ['accept', 'cookie', 'authorization'].every((v) => vary.includes(v)) &&
77
+ vary.every((v) => ['accept', 'cookie', 'authorization'].includes(v))
78
+ )
79
+ }
80
+
81
+ private until(response: Response, started: number) {
82
+ if (!this.allowed(response)) return 0
83
+ const parts = (response.headers.get('cache-control') ?? '')
84
+ .split(',')
85
+ .map((s) => s.trim().toLowerCase())
86
+ if (parts.some((s) => /^(?:no-cache|must-revalidate|proxy-revalidate)(?:\s*=|$)/.test(s)))
87
+ return 0
88
+ const max = parts.filter((s) => /^max-age=\d+$/.test(s))
89
+ const stale = parts.filter((s) => /^stale-while-revalidate=\d+$/.test(s))
90
+ if (
91
+ max.length !== 1 ||
92
+ stale.length > 1 ||
93
+ parts.some(
94
+ (s) =>
95
+ /^(?:max-age|stale-while-revalidate)\s*=/.test(s) &&
96
+ !/^(?:max-age|stale-while-revalidate)=\d+$/.test(s),
97
+ )
98
+ )
99
+ return 0
100
+ const age = response.headers.get('age')
101
+ if (age !== null && !/^\d+$/.test(age)) return 0
102
+ const date = response.headers.get('date')
103
+ const sent = date === null ? Date.now() : Date.parse(date)
104
+ if (!Number.isFinite(sent)) return 0
105
+ const seconds = Number(max[0]!.slice(8)) + Math.min(30, Number(stale[0]?.split('=')[1] ?? 0))
106
+ const now = performance.now()
107
+ const transit = Math.max(0, now - started)
108
+ const elapsed = Math.max(Number(age ?? 0) * 1000 + transit, Date.now() - sent, transit)
109
+ return now + Math.max(0, Math.min(30_000, seconds * 1000 - elapsed))
110
+ }
111
+
112
+ /** Read only a still-permitted body; callers must also prove its code is loaded. */
113
+ peek(url: URL, canSeed: boolean): Read | undefined {
114
+ const key = this.key(url),
115
+ entry = key && this.entries.get(key)
116
+ if (!key || !entry || performance.now() >= entry.until) return undefined
117
+ const next = JSON.parse(entry.body) as RoutePayload
118
+ if (next.c?.length && !canSeed) return undefined
119
+ this.entries.delete(key)
120
+ this.entries.set(key, entry)
121
+ return { next, since: entry.since, version: this.version, until: entry.until }
122
+ }
123
+
124
+ /** Conservative known route cost; unknown or mismatched receipts cannot be speculated. */
125
+ estimate(url: URL) {
126
+ const key = this.key(url),
127
+ entry = key && this.entries.get(key)
128
+ if (!entry) return undefined
129
+ const next = JSON.parse(entry.body) as RoutePayload
130
+ const cost = next.a
131
+ if (
132
+ !cost ||
133
+ cost.build !== this.build ||
134
+ cost.scope !== 'vite-static-closure' ||
135
+ typeof cost.routeBytes !== 'number' ||
136
+ !Number.isSafeInteger(cost.routeBytes) ||
137
+ cost.routeBytes < 0
138
+ )
139
+ return undefined
140
+ const data = Math.max(entry.bytes, 8 * 1024)
141
+ const total = data + cost.routeBytes
142
+ return Number.isSafeInteger(total) ? { data, total } : undefined
143
+ }
144
+
145
+ private store(
146
+ key: string | undefined,
147
+ response: Response,
148
+ body: string,
149
+ next: RoutePayload,
150
+ seed: { since: number; canSeed: boolean; started: number },
151
+ ) {
152
+ const { since, canSeed, started } = seed
153
+ if (!key) return
154
+ this.remove(key)
155
+ const tag = response.headers.get('etag')
156
+ const bytes = new TextEncoder().encode(body).byteLength
157
+ if (
158
+ response.status !== 200 ||
159
+ next.s !== 200 ||
160
+ next.r ||
161
+ !tag ||
162
+ !this.allowed(response) ||
163
+ (next.c?.length && !canSeed) ||
164
+ bytes > BYTES
165
+ )
166
+ return
167
+ const tags = [...new Set(next.m.flatMap((match) => match.t ?? []))]
168
+ this.entries.set(key, { body, bytes, tag, tags, since, until: this.until(response, started) })
169
+ this.bytes += bytes
170
+ while (this.entries.size > LIMIT || this.bytes > BYTES)
171
+ this.remove(this.entries.keys().next().value!)
172
+ }
173
+
174
+ read(
175
+ url: URL,
176
+ signal: AbortSignal,
177
+ fetchRoute: FetchRoute,
178
+ now: () => number,
179
+ canSeed: () => boolean,
180
+ ): Promise<Read> {
181
+ return this.load({ url, signal, fetchRoute, now, canSeed })
182
+ }
183
+
184
+ /** Fetch or validate a body against the current generation before returning it. */
185
+ async load(o: {
186
+ url: URL
187
+ signal: AbortSignal
188
+ fetchRoute: FetchRoute
189
+ now: () => number
190
+ canSeed: () => boolean
191
+ onForget?: (() => void) | undefined
192
+ }): Promise<Read> {
193
+ const { url, signal, fetchRoute, now, canSeed, onForget } = o
194
+ let allowTag = true
195
+ for (;;) {
196
+ if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
197
+ const version = this.version,
198
+ key = this.key(url)
199
+ const cached = key && allowTag ? this.entries.get(key) : undefined
200
+ let since = now()
201
+ const started = performance.now()
202
+ const response = await fetchRoute(url, signal, cached?.tag)
203
+ if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
204
+ if (version !== this.version) continue
205
+ if (response.status === 401 || response.status === 403) {
206
+ this.clear()
207
+ onForget?.()
208
+ throw new Error('Route authorization failed')
209
+ }
210
+ let body: string
211
+ if (response.status === 304) {
212
+ if (
213
+ !cached ||
214
+ !key ||
215
+ !this.allowed(response) ||
216
+ response.headers.get('etag') !== cached.tag ||
217
+ this.entries.get(key) !== cached
218
+ ) {
219
+ if (key) this.remove(key)
220
+ onForget?.()
221
+ if (!allowTag) throw new Error('Unexpected route 304')
222
+ allowTag = false
223
+ continue
224
+ }
225
+ body = cached.body
226
+ since = cached.since
227
+ cached.until = this.until(response, started)
228
+ this.entries.delete(key)
229
+ this.entries.set(key, cached)
230
+ } else {
231
+ body = await response.text()
232
+ if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
233
+ if (version !== this.version) continue
234
+ }
235
+ const next = JSON.parse(body) as RoutePayload
236
+ if (next.s === 401 || next.s === 403) {
237
+ this.clear()
238
+ return { next, since, version: this.version }
239
+ }
240
+ if (response.status === 304 && next.c?.length && !canSeed()) {
241
+ if (key) this.remove(key)
242
+ onForget?.()
243
+ allowTag = false
244
+ continue
245
+ }
246
+ if (response.status !== 304) {
247
+ if (next.p !== url.pathname + url.search && key) this.remove(key)
248
+ else this.store(key, response, body, next, { since, canSeed: canSeed(), started })
249
+ if (!next.r && !this.peek(url, canSeed())) onForget?.()
250
+ }
251
+ return { next, since, version, notModified: response.status === 304 }
252
+ }
253
+ }
254
+ }
@@ -0,0 +1,197 @@
1
+ import { flush } from '@weft-js/core'
2
+ import { rt } from '@weft-js/core/internal'
3
+ import type { Component, Match, RoutePayload } from '../router'
4
+ import type { ClientRoutes } from '../run'
5
+ import type { Navigation } from './navigation'
6
+
7
+ /**
8
+ * A deferred field on a page that loaded with its value still out. A chunk brings what's rendered from it; this is for
9
+ * the render that hasn't one, a Boundary's retry or one the stream gave up on. The route has it, and only that asks.
10
+ */
11
+ export function deferred(path: string, level: number, key: string): PromiseLike<unknown> {
12
+ let got: Promise<unknown> | undefined
13
+ const value = () => {
14
+ if (rt.probe) return new Promise(() => {}) // a hydrate run only tracks its reads, and nothing waits on what it gets
15
+ if (!got) {
16
+ got = ask(new URL(path, location.href))
17
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error('Server function failed.'))))
18
+ .then((next: RoutePayload) => (next.m[level]!.data as Record<string, unknown>)[key])
19
+ got.catch(() => (got = undefined))
20
+ }
21
+ return got
22
+ }
23
+ return {
24
+ // oxlint-disable-next-line unicorn/no-thenable -- it is a promise, one that asks the server only once something awaits it
25
+ then: (ok, no) => value().then(ok, no),
26
+ catch: (no?: (e: unknown) => unknown) => value().catch(no),
27
+ finally: (fn?: () => void) => value().finally(fn),
28
+ } as PromiseLike<unknown>
29
+ }
30
+
31
+ export const ask = (url: URL, signal?: AbortSignal, tag?: string) =>
32
+ fetch(`/_weft/data${url.pathname === '/' ? '' : url.pathname}${url.search}`, {
33
+ signal: signal ?? null,
34
+ cache: 'no-cache',
35
+ headers: { accept: 'application/json', ...(tag && { 'if-none-match': tag }) },
36
+ })
37
+
38
+ /** Whether every client component and island required by a route is already loaded. */
39
+ export function routeCodeReady(
40
+ next: RoutePayload,
41
+ comps: Record<string, Component>,
42
+ islands: Record<string, Component>,
43
+ ) {
44
+ return next.m.every((m) => m.s || !!comps[m.id]) && (next.i ?? []).every((id) => !!islands[id])
45
+ }
46
+
47
+ /** The URL a client navigation would take from this event's link, or nothing for a link the browser should follow. */
48
+ export function ours(target: EventTarget | null) {
49
+ const a = (target as Element | null)?.closest?.('a')
50
+ if (
51
+ !a ||
52
+ (a.target && a.target.toLowerCase() !== '_self') ||
53
+ a.hasAttribute('download') ||
54
+ [...a.relList].some((token) => token.toLowerCase() === 'external')
55
+ )
56
+ return
57
+ const url = new URL(a.href, location.href)
58
+ if (url.origin !== location.origin || url.pathname.startsWith('/_weft/')) return
59
+ return url.pathname === location.pathname && url.search === location.search && url.hash
60
+ ? undefined
61
+ : url // not an in-page anchor
62
+ }
63
+
64
+ /** A kept static level has no binding for its links' current-page state: the same rule as `Link`, after the swap. */
65
+ export function current_(path: string) {
66
+ flush()
67
+ for (const a of document.querySelectorAll<HTMLAnchorElement>('a[href]')) {
68
+ let match = false
69
+ try {
70
+ const url = new URL(a.href)
71
+ const section = a.dataset.match === 'section'
72
+ match =
73
+ url.origin === location.origin &&
74
+ !url.search &&
75
+ !url.hash &&
76
+ (url.pathname === path ||
77
+ (section &&
78
+ url.pathname !== '/' &&
79
+ path.startsWith(`${url.pathname.replace(/\/$/, '')}/`)))
80
+ } catch {
81
+ /* malformed links cannot match */
82
+ }
83
+ if (match && !a.hasAttribute('aria-current')) a.setAttribute('aria-current', 'page')
84
+ else if (!match && a.getAttribute('aria-current') === 'page') a.removeAttribute('aria-current')
85
+ }
86
+ }
87
+
88
+ /** Deferred fields arrive resolved on navigation: they become settled promises again, so resources read them alike. */
89
+ export function revive(m: Match): Match {
90
+ if (!m.d?.length) return m
91
+ const data = { ...(m.data as Record<string, unknown>) }
92
+ for (const k of m.d) data[k] = Promise.resolve(data[k])
93
+ return { ...m, data }
94
+ }
95
+
96
+ /** The route ids `pathname` probably needs (its layouts and page), from the table; the server's answer decides. */
97
+ export function guess(routes: ClientRoutes, pathname: string): string[] {
98
+ // A server load canonicalizes trailing slashes before it has a page match.
99
+ if (pathname.length > 1 && pathname.endsWith('/')) return []
100
+ let parts: string[]
101
+ try {
102
+ const path = pathname.replace(/^\/+|\/+$/g, '')
103
+ parts = path ? path.split('/').map(decodeURIComponent) : []
104
+ } catch {
105
+ return []
106
+ }
107
+ for (const r of routes.table) {
108
+ const segs = r.pattern.split('/').filter(Boolean)
109
+ const splat = segs.at(-1) === '$'
110
+ if (splat ? parts.length < segs.length - 1 : parts.length !== segs.length) continue
111
+ if (
112
+ segs.every(
113
+ (s, i) =>
114
+ s === '$' ||
115
+ (s.startsWith('$') ? !!parts[i] : s.toLowerCase() === parts[i]?.toLowerCase()),
116
+ )
117
+ )
118
+ return [...r.layouts, r.id]
119
+ }
120
+ return []
121
+ }
122
+
123
+ /** Find the outlet whose route parameter changed against the committed URL. */
124
+ export function changedRouteParams(
125
+ routes: ClientRoutes,
126
+ committedURL: string,
127
+ ids: string[],
128
+ url: URL,
129
+ ) {
130
+ const from = new URL(committedURL)
131
+ if (from.pathname === url.pathname && from.search === url.search) return -1
132
+ const route = routes.table.find((item) => item.id === ids.at(-1))
133
+ if (!route || guess(routes, from.pathname).join('\0') !== ids.join('\0')) return -1
134
+ try {
135
+ const partsOf = (path: string) => {
136
+ const trimmed = path.replace(/^\/+|\/+$/g, '')
137
+ return trimmed ? trimmed.split('/').map(decodeURIComponent) : []
138
+ }
139
+ const before = partsOf(from.pathname)
140
+ const after = partsOf(url.pathname)
141
+ const parts = route.pattern.split('/').filter(Boolean)
142
+ const changed = parts.findIndex(
143
+ (part, index) =>
144
+ part.startsWith('$') &&
145
+ (part === '$'
146
+ ? JSON.stringify(before.slice(index)) !== JSON.stringify(after.slice(index))
147
+ : before[index] !== after[index]),
148
+ )
149
+ if (changed >= 0) {
150
+ const layout = ids.findIndex(
151
+ (id, depth) =>
152
+ depth < ids.length - 1 &&
153
+ id.startsWith('layout:') &&
154
+ id.slice(7).split('/').filter(Boolean).length > changed,
155
+ )
156
+ return layout < 0 ? ids.length - 1 : layout
157
+ }
158
+ return from.search !== url.search ? ids.length - 1 : -1
159
+ } catch {
160
+ return -1
161
+ }
162
+ }
163
+
164
+ const SLOW_FADE = 100
165
+
166
+ /** Swaps inside a view transition when the page can take one, and plainly when not. */
167
+ export async function crossfade(
168
+ nav: Pick<Navigation, 'fades' | 'painted' | 'fading'>,
169
+ swap: () => void,
170
+ swapped: () => boolean,
171
+ ) {
172
+ if (
173
+ !(
174
+ nav.fades &&
175
+ nav.painted &&
176
+ 'startViewTransition' in document &&
177
+ !document.hidden &&
178
+ !matchMedia('(prefers-reduced-motion: reduce)').matches
179
+ )
180
+ )
181
+ return swap()
182
+ const started = performance.now()
183
+ const fade = (nav.fading = document.startViewTransition(swap))
184
+ void fade.ready
185
+ .then(() => {
186
+ if (performance.now() - started > SLOW_FADE) nav.fades = false
187
+ return fade.finished
188
+ })
189
+ .catch(() => {})
190
+ .then(() => {
191
+ if (nav.fading === fade) nav.fading = undefined
192
+ })
193
+ // A transition the browser aborts rejects `finished`: the page already swapped.
194
+ void fade.finished.catch(() => {})
195
+ // Aborted before its update ran ("invalid state"): swap without the fade.
196
+ await fade.updateCallbackDone.catch(() => swapped() || swap())
197
+ }