@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
package/src/run/boot.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/run/cache.ts
ADDED
|
@@ -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
|
+
}
|