@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.
- package/README.md +36 -3
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -0
- package/crate/rust-toolchain.toml +3 -0
- package/crate/src/build/emit.rs +103 -0
- package/crate/src/build.rs +367 -0
- package/crate/src/ctx.rs +186 -0
- package/crate/src/envelope.rs +163 -0
- package/crate/src/functions.rs +173 -0
- package/crate/src/lib.rs +118 -0
- package/crate/src/native.rs +212 -0
- package/crate/src/otel.rs +62 -0
- package/crate/src/server.rs +203 -0
- package/package.json +46 -37
- package/src/app.mjs +51 -10
- package/src/asset-receipt.d.mts +13 -0
- package/src/asset-receipt.mjs +61 -0
- package/src/auth/better-auth.ts +215 -17
- package/src/auth/drizzle.ts +1 -1
- package/src/auth.ts +21 -6
- package/src/boot.ts +19 -8
- package/src/bus/postgres.ts +32 -19
- package/src/bus.ts +7 -4
- package/src/{capture.mjs → capture.ts} +34 -1
- package/src/changes-sql.d.mts +8 -2
- package/src/changes-sql.mjs +26 -10
- package/src/changes.ts +165 -66
- package/src/channel/connections.ts +216 -0
- package/src/channel/socket.ts +396 -0
- package/src/channel-fake.ts +19 -4
- package/src/channel.ts +124 -490
- package/src/client.tsx +9 -2
- package/src/contract/describe.mjs +231 -0
- package/src/contract.mjs +146 -155
- package/src/data/cache.ts +354 -0
- package/src/data/definitions.ts +94 -0
- package/src/data/draft.ts +104 -0
- package/src/data/foundation.ts +95 -0
- package/src/data/live-api.ts +22 -0
- package/src/data/mutation.ts +138 -0
- package/src/data/primitives.ts +135 -0
- package/src/data/query.ts +106 -0
- package/src/data/transport.ts +121 -0
- package/src/data/types.ts +303 -0
- package/src/data.ts +69 -894
- package/src/db/bun-sqlite.d.ts +4 -1
- package/src/db/changes-mysql.ts +61 -21
- package/src/db/changes-postgres.ts +197 -118
- package/src/db/changes-sqlite.ts +131 -64
- package/src/db/core.ts +38 -13
- package/src/db/index.ts +20 -4
- package/src/db/mysql.ts +22 -6
- package/src/db/postgres.ts +49 -13
- package/src/db/sqlite.ts +53 -27
- package/src/derive.d.mts +4 -1
- package/src/derive.mjs +136 -39
- package/src/dev-runtime.mjs +8 -4
- package/src/exec/errors.ts +57 -0
- package/src/exec/landing.ts +66 -0
- package/src/exec/scope.ts +174 -0
- package/src/exec/touch.ts +80 -0
- package/src/exec/validation.ts +176 -0
- package/src/exec.ts +191 -341
- package/src/extension.ts +8 -2
- package/src/first-paint.ts +26 -9
- package/src/form/value.ts +15 -0
- package/src/form.tsx +132 -43
- package/src/hub/channels.ts +102 -0
- package/src/hub/connections.ts +147 -0
- package/src/hub/delivery.ts +62 -0
- package/src/hub/feed.ts +168 -0
- package/src/hub/presence.ts +64 -0
- package/src/hub/route.ts +158 -0
- package/src/hub/subscriptions.ts +212 -0
- package/src/hub/topic.ts +98 -0
- package/src/hub/tracked.ts +12 -0
- package/src/hub/types.ts +190 -0
- package/src/hub/view.ts +147 -0
- package/src/hub.ts +132 -758
- package/src/index.ts +84 -10
- package/src/inline.mjs +356 -0
- package/src/inspect/compare.mjs +162 -0
- package/src/inspect/routes.mjs +154 -0
- package/src/inspect/samples.mjs +59 -0
- package/src/inspect/url.mjs +2 -0
- package/src/inspect.mjs +146 -187
- package/src/link/election.ts +178 -0
- package/src/link/lines.ts +27 -0
- package/src/link.ts +97 -168
- package/src/list-drizzle.ts +104 -18
- package/src/list.ts +127 -36
- package/src/listen.ts +32 -6
- package/src/live/api.ts +18 -0
- package/src/live/backoff.ts +6 -0
- package/src/live/frames.ts +42 -0
- package/src/live/read.ts +77 -0
- package/src/live/sub-types.ts +24 -0
- package/src/live/transport.ts +86 -0
- package/src/live/types.ts +52 -0
- package/src/live-fake.ts +20 -5
- package/src/live-target.mjs +113 -35
- package/src/live.ts +116 -225
- package/src/media.ts +8 -1
- package/src/migrations.mjs +14 -6
- package/src/mux.ts +161 -64
- package/src/og/DefaultCard.tsx +40 -9
- package/src/og/fonts.mjs +16 -8
- package/src/og/images.mjs +43 -22
- package/src/og/index.mjs +119 -43
- package/src/og/native.mjs +11 -4
- package/src/range.mjs +15 -3
- package/src/router.ts +56 -6
- package/src/routes.mjs +73 -22
- package/src/run/admission.ts +80 -0
- package/src/run/boot.tsx +115 -0
- package/src/run/cache-presentation.ts +67 -0
- package/src/run/cache.ts +254 -0
- package/src/run/helpers.ts +197 -0
- package/src/run/listeners.ts +155 -0
- package/src/run/navigation.ts +382 -0
- package/src/run/page-skeleton.tsx +35 -0
- package/src/run/prefetch.ts +128 -0
- package/src/run/retain-view.ts +18 -0
- package/src/run/shape-preparation.ts +97 -0
- package/src/run/shape-styles.ts +72 -0
- package/src/run/source-context/pin.ts +124 -0
- package/src/run/source-context.ts +311 -0
- package/src/run.tsx +50 -174
- package/src/rust/common.ts +163 -0
- package/src/rust/native.ts +282 -0
- package/src/rust.ts +103 -229
- package/src/server/card.tsx +65 -0
- package/src/server/compression.ts +115 -0
- package/src/server/core.ts +70 -0
- package/src/server/dispatch.ts +70 -0
- package/src/server/form.ts +85 -0
- package/src/server/functions.ts +129 -0
- package/src/server/handler.ts +39 -0
- package/src/server/head.ts +96 -0
- package/src/server/media.ts +60 -0
- package/src/server/navigation-cache.ts +80 -0
- package/src/server/navigation.tsx +85 -0
- package/src/server/page-cold.ts +95 -0
- package/src/server/page-stream.tsx +181 -0
- package/src/server/page-types.ts +51 -0
- package/src/server/page-warm.ts +130 -0
- package/src/server/page.ts +69 -0
- package/src/server/payload.ts +89 -0
- package/src/server/prerender.ts +60 -0
- package/src/server/preview.tsx +67 -0
- package/src/server/render.ts +167 -0
- package/src/server/request.ts +98 -0
- package/src/server/resolver.ts +228 -0
- package/src/server/routes.ts +150 -0
- package/src/server/services.ts +69 -0
- package/src/server/text.ts +29 -0
- package/src/server/types.ts +160 -0
- package/src/server.tsx +82 -964
- package/src/shape.mjs +30 -6
- package/src/share/display.ts +50 -0
- package/src/share/network.ts +119 -0
- package/src/share/receive.ts +70 -0
- package/src/share/state.ts +30 -0
- package/src/share/tab-queue.ts +28 -0
- package/src/share-server.ts +128 -43
- package/src/share.ts +103 -209
- package/src/sheets.mjs +30 -18
- package/src/sidecar/build.mjs +170 -0
- package/src/sidecar/cargo.mjs +164 -0
- package/src/sidecar/common.mjs +17 -0
- package/src/sidecar/dev-build.mjs +91 -0
- package/src/sidecar/dev-watch.mjs +88 -0
- package/src/sidecar/manifest.mjs +19 -0
- package/src/sidecar/process.mjs +58 -0
- package/src/sidecar/refresh.mjs +19 -0
- package/src/sidecar.mjs +75 -416
- package/src/socket/check.ts +50 -0
- package/src/socket/deliver.ts +16 -0
- package/src/socket/publish.ts +56 -0
- package/src/socket/rooms.ts +41 -0
- package/src/socket/route.ts +79 -0
- package/src/socket/shared.ts +26 -0
- package/src/socket/types.ts +48 -0
- package/src/socket.ts +205 -194
- package/src/stream.ts +10 -1
- package/src/verify/app.ts +39 -0
- package/src/verify/cleanup.ts +27 -0
- package/src/verify/database.ts +11 -0
- package/src/verify/live.ts +63 -0
- package/src/verify/notify.ts +35 -0
- package/src/verify/skip.ts +19 -0
- package/src/verify/support.ts +149 -0
- package/src/verify/timing.ts +15 -0
- package/src/verify/trigger.ts +14 -0
- package/src/verify.ts +77 -176
- package/src/view.tsx +96 -11
- package/src/vite/asset-bytes.mjs +95 -0
- package/src/vite/assets.mjs +117 -0
- package/src/vite/config-hooks.mjs +166 -0
- package/src/vite/dev-hooks.mjs +103 -0
- package/src/vite/dev.mjs +102 -0
- package/src/vite/entries.mjs +145 -0
- package/src/vite/module-hooks.mjs +288 -0
- package/src/vite/route-modules.mjs +289 -0
- package/src/vite/server-functions.mjs +222 -0
- package/src/vite/shape-entry-css.mjs +187 -0
- package/src/vite/shape-override.mjs +226 -0
- package/src/vite/shared.mjs +51 -0
- package/src/vite/sheets.mjs +92 -0
- package/src/vite/state.mjs +89 -0
- package/src/vite/syntax.mjs +139 -0
- package/src/vite.mjs +152 -853
- 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
|
+
}
|