@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,18 @@
|
|
|
1
|
+
import { createRoot, getOwner, onCleanup, state } from '@weft-js/core'
|
|
2
|
+
import { hole, rt } from '@weft-js/core/internal'
|
|
3
|
+
|
|
4
|
+
/** Keep one route view alive while its pending shape occupies the same outlet. */
|
|
5
|
+
export function retainRouteView(view: () => unknown, pending: () => unknown) {
|
|
6
|
+
if (rt.server) return () => pending() ?? view()
|
|
7
|
+
const parent = getOwner()
|
|
8
|
+
const content = state<unknown>(undefined)
|
|
9
|
+
let dispose = () => {}
|
|
10
|
+
createRoot((end) => {
|
|
11
|
+
dispose = end
|
|
12
|
+
hole(view, (value) => {
|
|
13
|
+
content.value = value
|
|
14
|
+
})
|
|
15
|
+
}, parent)
|
|
16
|
+
onCleanup(() => dispose())
|
|
17
|
+
return () => pending() ?? content.value
|
|
18
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { flush } from '@weft-js/core'
|
|
2
|
+
import { level, type Component } from '../router'
|
|
3
|
+
import type { ClientRoutes, RouteShape } from '../run'
|
|
4
|
+
import { acquireShapeStyles } from './shape-styles'
|
|
5
|
+
import { PageSkeleton, PassThroughSkeleton } from './page-skeleton'
|
|
6
|
+
import type { Navigation } from './navigation'
|
|
7
|
+
|
|
8
|
+
export interface PreparedRouteShape {
|
|
9
|
+
kind: 'generated' | 'override'
|
|
10
|
+
component: Component
|
|
11
|
+
/** Provided CSS is installed; its dependency closure remains unproven. */
|
|
12
|
+
ready: true
|
|
13
|
+
completeness: 'partial'
|
|
14
|
+
release(): void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Renderable = Extract<RouteShape, { kind: 'generated' | 'override' }>
|
|
18
|
+
|
|
19
|
+
/** Prepare the selected generated or custom shape without rendering it. */
|
|
20
|
+
export function prepareRouteShape(
|
|
21
|
+
routes: ClientRoutes,
|
|
22
|
+
id: string,
|
|
23
|
+
doc: Document,
|
|
24
|
+
): PreparedRouteShape | null {
|
|
25
|
+
const shape = routes.shapes?.[id]
|
|
26
|
+
if (!shape || (shape.kind !== 'generated' && shape.kind !== 'override')) return null
|
|
27
|
+
// A shape with no structure of its own would leave the outlet blank: a page's outline stands in, and a layout's
|
|
28
|
+
// place goes to its page's.
|
|
29
|
+
if (shape.thin)
|
|
30
|
+
return prepare(
|
|
31
|
+
{
|
|
32
|
+
...shape,
|
|
33
|
+
component: id.startsWith('layout:') ? PassThroughSkeleton : PageSkeleton,
|
|
34
|
+
},
|
|
35
|
+
routes.build,
|
|
36
|
+
doc,
|
|
37
|
+
)
|
|
38
|
+
return prepare(shape, routes.build, doc)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function prepare(
|
|
42
|
+
shape: Renderable,
|
|
43
|
+
build: string | null | undefined,
|
|
44
|
+
doc: Document,
|
|
45
|
+
): PreparedRouteShape | null {
|
|
46
|
+
const lease = acquireShapeStyles(doc, build, shape.cssText)
|
|
47
|
+
if (!lease) return null
|
|
48
|
+
return {
|
|
49
|
+
kind: shape.kind,
|
|
50
|
+
component: shape.component,
|
|
51
|
+
ready: true,
|
|
52
|
+
completeness: shape.cssStatus,
|
|
53
|
+
// oxlint-disable-next-line typescript/unbound-method -- the native method is kept to be called with .call()
|
|
54
|
+
release: lease.release,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Prepare all changed levels before replacing the current outlet. */
|
|
59
|
+
export function presentShape(nav: Navigation, ids: string[], url: URL, force: boolean) {
|
|
60
|
+
let first = ids.findIndex((id, depth) => nav.route.levels[depth]?.id.value !== id)
|
|
61
|
+
if (first < 0) first = force ? ids.length - 1 : nav.changedParams(ids, url)
|
|
62
|
+
if (first < 0) {
|
|
63
|
+
const committed = new URL(nav.committedURL)
|
|
64
|
+
if (url.pathname === committed.pathname && url.search === committed.search) nav.clearShape()
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
if (!nav.route.prepareShape) return false
|
|
68
|
+
const leases: PreparedRouteShape[] = []
|
|
69
|
+
try {
|
|
70
|
+
for (const id of ids.slice(first)) {
|
|
71
|
+
const lease = nav.route.prepareShape(id)
|
|
72
|
+
if (!lease) {
|
|
73
|
+
leases.forEach((item) => item.release())
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
leases.push(lease)
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
leases.forEach((item) => item.release())
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
// A same-id layout stays mounted; its existing child outlet owns the presentation.
|
|
83
|
+
let depth = first
|
|
84
|
+
while (depth < ids.length - 1 && nav.route.levels[depth]?.id.value === ids[depth]) depth++
|
|
85
|
+
const retained = leases.splice(0, depth - first)
|
|
86
|
+
retained.forEach((lease) => lease.release())
|
|
87
|
+
const outlet = level(nav.route, depth)
|
|
88
|
+
const old = nav.detachShape()
|
|
89
|
+
outlet.shape.value = leases
|
|
90
|
+
nav.shape = { outlet, leases }
|
|
91
|
+
try {
|
|
92
|
+
flush()
|
|
93
|
+
} finally {
|
|
94
|
+
old?.forEach((lease) => lease.release())
|
|
95
|
+
}
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
interface SheetLease {
|
|
2
|
+
release(): void
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
interface SheetEntry {
|
|
6
|
+
element: HTMLStyleElement
|
|
7
|
+
count: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const sheets = new WeakMap<Document, Map<string, SheetEntry>>()
|
|
11
|
+
|
|
12
|
+
function hasImports(rules: CSSRuleList): boolean {
|
|
13
|
+
for (const rule of Array.from(rules)) {
|
|
14
|
+
if (rule.type === 3) return true
|
|
15
|
+
if ('cssRules' in rule && hasImports((rule as CSSGroupingRule).cssRules)) return true
|
|
16
|
+
}
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Apply provided shape CSS before app styles. A lease owns only the styles it creates. */
|
|
21
|
+
export function acquireShapeStyles(
|
|
22
|
+
doc: Document,
|
|
23
|
+
build: string | null | undefined,
|
|
24
|
+
text: string[],
|
|
25
|
+
): SheetLease | null {
|
|
26
|
+
if (!doc.head || text.some((css) => /@import\b/i.test(css))) return null
|
|
27
|
+
const key = JSON.stringify([build ?? null, text])
|
|
28
|
+
const entries = sheets.get(doc) ?? new Map<string, SheetEntry>()
|
|
29
|
+
if (!sheets.has(doc)) sheets.set(doc, entries)
|
|
30
|
+
let entry = entries.get(key)
|
|
31
|
+
if (entry && entry.element.parentNode !== doc.head) {
|
|
32
|
+
entries.delete(key)
|
|
33
|
+
entry = undefined
|
|
34
|
+
}
|
|
35
|
+
if (!entry) {
|
|
36
|
+
const element = doc.createElement('style')
|
|
37
|
+
element.setAttribute('data-weft-shape-style', '')
|
|
38
|
+
const nonce = doc.querySelector<HTMLMetaElement>('meta[property="csp-nonce"]')?.nonce
|
|
39
|
+
if (nonce) element.nonce = nonce
|
|
40
|
+
element.textContent = text.join('\n')
|
|
41
|
+
const owned = new Set<Element>([...entries.values()].map((item) => item.element))
|
|
42
|
+
const appStyle =
|
|
43
|
+
[...doc.head.querySelectorAll('style,link[rel~="stylesheet"]')].find(
|
|
44
|
+
(item) => !owned.has(item),
|
|
45
|
+
) ?? null
|
|
46
|
+
try {
|
|
47
|
+
doc.head.insertBefore(element, appStyle)
|
|
48
|
+
if (!element.sheet || hasImports(element.sheet.cssRules)) {
|
|
49
|
+
element.remove()
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
element.remove()
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
entry = { element, count: 0 }
|
|
57
|
+
entries.set(key, entry)
|
|
58
|
+
}
|
|
59
|
+
entry.count++
|
|
60
|
+
const owned = entry
|
|
61
|
+
let released = false
|
|
62
|
+
return {
|
|
63
|
+
release() {
|
|
64
|
+
if (released) return
|
|
65
|
+
released = true
|
|
66
|
+
if (--owned.count === 0) {
|
|
67
|
+
owned.element.remove()
|
|
68
|
+
if (entries.get(key) === owned) entries.delete(key)
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
export type Name = 'href' | 'action' | 'formaction'
|
|
2
|
+
export interface Saved {
|
|
3
|
+
original: string | null
|
|
4
|
+
installed: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface Wrapper {
|
|
8
|
+
target: object
|
|
9
|
+
name: string
|
|
10
|
+
descriptor: PropertyDescriptor
|
|
11
|
+
replacement: PropertyDescriptor
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Retained {
|
|
15
|
+
root: HTMLElement
|
|
16
|
+
win: Window & typeof globalThis
|
|
17
|
+
source: string
|
|
18
|
+
base: string
|
|
19
|
+
saved: Map<Element, Map<Name, Saved>>
|
|
20
|
+
owners: WeakMap<NamedNodeMap, Element>
|
|
21
|
+
nativeSet: Element['setAttribute']
|
|
22
|
+
nativeRemove: Element['removeAttribute']
|
|
23
|
+
attrValue: PropertyDescriptor['set']
|
|
24
|
+
stopped: boolean
|
|
25
|
+
wrappers: Wrapper[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const SELECTOR = 'a[href],form,button[formaction],input[formaction]'
|
|
29
|
+
|
|
30
|
+
export const relevant = (item: Element, name: string): name is Name =>
|
|
31
|
+
(item.localName === 'a' && name === 'href') ||
|
|
32
|
+
(item.localName === 'form' && name === 'action') ||
|
|
33
|
+
((item.localName === 'button' || item.localName === 'input') && name === 'formaction')
|
|
34
|
+
|
|
35
|
+
export function pin(
|
|
36
|
+
c: Retained,
|
|
37
|
+
item: Element,
|
|
38
|
+
name: Name,
|
|
39
|
+
raw: string | null,
|
|
40
|
+
force = false,
|
|
41
|
+
): string | null {
|
|
42
|
+
const records = c.saved.get(item)
|
|
43
|
+
if (!force && records?.get(name)?.installed === raw) return raw
|
|
44
|
+
if (raw === null && name !== 'action') {
|
|
45
|
+
records?.delete(name)
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
// Absolute and root-relative URLs resolve the same from any path: leave them alone.
|
|
49
|
+
if (raw && raw !== records?.get(name)?.installed && /^(?:[a-z][a-z\d+.-]*:|\/)/i.test(raw)) {
|
|
50
|
+
records?.delete(name)
|
|
51
|
+
return raw
|
|
52
|
+
}
|
|
53
|
+
let resolved: string
|
|
54
|
+
try {
|
|
55
|
+
resolved = name !== 'href' && !raw ? c.source : new URL(raw ?? '', c.base).href
|
|
56
|
+
} catch {
|
|
57
|
+
records?.delete(name)
|
|
58
|
+
return raw
|
|
59
|
+
}
|
|
60
|
+
if (raw === resolved) {
|
|
61
|
+
records?.delete(name)
|
|
62
|
+
return raw
|
|
63
|
+
}
|
|
64
|
+
const own = records ?? new Map<Name, Saved>()
|
|
65
|
+
c.saved.set(item, own)
|
|
66
|
+
own.set(name, { original: raw, installed: resolved })
|
|
67
|
+
return resolved
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function patch(c: Retained, item: Element, name: Name) {
|
|
71
|
+
const raw = item.getAttribute(name)
|
|
72
|
+
const resolved = pin(c, item, name, raw)
|
|
73
|
+
if (resolved !== raw) {
|
|
74
|
+
if (resolved === null) Reflect.apply(c.nativeRemove, item, [name])
|
|
75
|
+
else Reflect.apply(c.nativeSet, item, [name, resolved])
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function patchItem(c: Retained, item: Element) {
|
|
80
|
+
void item.attributes // Register maps captured before this context began.
|
|
81
|
+
if (item.localName === 'a') patch(c, item, 'href')
|
|
82
|
+
if (item.localName === 'form') patch(c, item, 'action')
|
|
83
|
+
if (item.localName === 'button' || item.localName === 'input') patch(c, item, 'formaction')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function scan(c: Retained, parent: ParentNode) {
|
|
87
|
+
for (const item of parent.querySelectorAll(SELECTOR)) patchItem(c, item)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function ownerOf(c: Retained, map: NamedNodeMap) {
|
|
91
|
+
const known = c.owners.get(map)
|
|
92
|
+
if (known) return known
|
|
93
|
+
if (c.root.matches('a,form,button,input') && c.root.attributes === map) return c.root
|
|
94
|
+
for (const item of c.root.querySelectorAll('a,form,button,input'))
|
|
95
|
+
if (item.attributes === map) return item
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function changedNode(
|
|
100
|
+
c: Retained,
|
|
101
|
+
item: Element | null | undefined,
|
|
102
|
+
attr: unknown,
|
|
103
|
+
added: boolean,
|
|
104
|
+
previous: Attr | null,
|
|
105
|
+
) {
|
|
106
|
+
if (
|
|
107
|
+
c.stopped ||
|
|
108
|
+
!item ||
|
|
109
|
+
!c.root.contains(item) ||
|
|
110
|
+
!(attr instanceof c.win.Attr) ||
|
|
111
|
+
attr.namespaceURI !== null ||
|
|
112
|
+
!relevant(item, attr.localName.toLowerCase())
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
const name = attr.localName.toLowerCase() as Name
|
|
116
|
+
if (added) {
|
|
117
|
+
const raw = attr.value,
|
|
118
|
+
value = pin(c, item, name, raw, previous !== attr)
|
|
119
|
+
if (value !== raw) Reflect.apply(c.attrValue!, attr, [value])
|
|
120
|
+
} else {
|
|
121
|
+
const value = pin(c, item, name, null, true)
|
|
122
|
+
if (value !== null) Reflect.apply(c.nativeSet, item, [name, value])
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import {
|
|
2
|
+
changedNode,
|
|
3
|
+
ownerOf,
|
|
4
|
+
patchItem,
|
|
5
|
+
pin,
|
|
6
|
+
relevant,
|
|
7
|
+
scan,
|
|
8
|
+
SELECTOR,
|
|
9
|
+
type Retained,
|
|
10
|
+
} from './source-context/pin'
|
|
11
|
+
|
|
12
|
+
export interface SourceContext {
|
|
13
|
+
/** Stop observing before incoming setup; keep controls pinned until the swap. */
|
|
14
|
+
stop(): void
|
|
15
|
+
release(): void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Original = (...args: unknown[]) => unknown
|
|
19
|
+
|
|
20
|
+
const active = new WeakSet<Document>()
|
|
21
|
+
const EVENTS = ['pointerdown', 'click', 'auxclick', 'submit']
|
|
22
|
+
const AFTER = ['click', 'auxclick', 'submit']
|
|
23
|
+
|
|
24
|
+
function install(
|
|
25
|
+
c: Retained,
|
|
26
|
+
target: object,
|
|
27
|
+
name: string,
|
|
28
|
+
make: (d: PropertyDescriptor) => PropertyDescriptor,
|
|
29
|
+
required = true,
|
|
30
|
+
) {
|
|
31
|
+
const descriptor = Object.getOwnPropertyDescriptor(target, name)
|
|
32
|
+
if (!descriptor) {
|
|
33
|
+
if (required) throw new Error('Missing control hook')
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
if (!descriptor.configurable) throw new Error('Locked control hook')
|
|
37
|
+
const replacement = make(descriptor)
|
|
38
|
+
Object.defineProperty(target, name, replacement)
|
|
39
|
+
c.wrappers.push({ target, name, descriptor, replacement })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function restore(c: Retained) {
|
|
43
|
+
for (const { target, name, descriptor, replacement } of c.wrappers.reverse()) {
|
|
44
|
+
const now = Object.getOwnPropertyDescriptor(target, name)
|
|
45
|
+
if (
|
|
46
|
+
now &&
|
|
47
|
+
now.get === replacement.get &&
|
|
48
|
+
now.set === replacement.set &&
|
|
49
|
+
now.value === replacement.value &&
|
|
50
|
+
now.writable === replacement.writable &&
|
|
51
|
+
now.enumerable === replacement.enumerable &&
|
|
52
|
+
now.configurable === replacement.configurable
|
|
53
|
+
)
|
|
54
|
+
Object.defineProperty(target, name, descriptor)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function wrapMethods(
|
|
59
|
+
c: Retained,
|
|
60
|
+
target: object,
|
|
61
|
+
names: string[],
|
|
62
|
+
make: (name: string, original: Original) => Original,
|
|
63
|
+
) {
|
|
64
|
+
for (const name of names)
|
|
65
|
+
install(c, target, name, (descriptor) => ({
|
|
66
|
+
...descriptor,
|
|
67
|
+
value: make(name, descriptor.value as Original),
|
|
68
|
+
}))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hookAttributes(c: Retained) {
|
|
72
|
+
install(c, c.win.Element.prototype, 'attributes', (descriptor) => ({
|
|
73
|
+
...descriptor,
|
|
74
|
+
get(this: Element) {
|
|
75
|
+
// oxlint-disable-next-line typescript/unbound-method -- the native method is kept to be called with .call()
|
|
76
|
+
const map = Reflect.apply(descriptor.get!, this, []) as NamedNodeMap
|
|
77
|
+
if (!c.stopped) c.owners.set(map, this)
|
|
78
|
+
return map
|
|
79
|
+
},
|
|
80
|
+
}))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hookValues(c: Retained) {
|
|
84
|
+
const { win, root } = c
|
|
85
|
+
for (const [prototype, property] of [
|
|
86
|
+
[win.Attr.prototype, 'value'],
|
|
87
|
+
[win.Node.prototype, 'nodeValue'],
|
|
88
|
+
[win.Node.prototype, 'textContent'],
|
|
89
|
+
] as const) {
|
|
90
|
+
install(c, prototype, property, (descriptor) => ({
|
|
91
|
+
...descriptor,
|
|
92
|
+
set(this: Node, value: string | null) {
|
|
93
|
+
if (!c.stopped && this instanceof win.Attr && this.namespaceURI === null) {
|
|
94
|
+
const item = this.ownerElement,
|
|
95
|
+
name = this.localName.toLowerCase()
|
|
96
|
+
if (item && root.contains(item) && relevant(item, name)) {
|
|
97
|
+
// oxlint-disable-next-line typescript/no-unnecessary-type-conversion -- a DOM setter can receive null at runtime
|
|
98
|
+
const raw = property === 'value' ? value : String(value ?? '')
|
|
99
|
+
value = pin(c, item, name, raw, true)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// oxlint-disable-next-line typescript/unbound-method -- the native method is kept to be called with .call()
|
|
103
|
+
Reflect.apply(descriptor.set!, this, [value])
|
|
104
|
+
},
|
|
105
|
+
}))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function hookNamedNodeMap(c: Retained) {
|
|
110
|
+
const { win } = c
|
|
111
|
+
const names = ['setNamedItem', 'setNamedItemNS', 'removeNamedItem', 'removeNamedItemNS']
|
|
112
|
+
wrapMethods(
|
|
113
|
+
c,
|
|
114
|
+
win.NamedNodeMap.prototype,
|
|
115
|
+
names,
|
|
116
|
+
(method, original) =>
|
|
117
|
+
function (this: NamedNodeMap, ...args: unknown[]) {
|
|
118
|
+
const added = method.startsWith('set')
|
|
119
|
+
const owner = !c.stopped && !added ? ownerOf(c, this) : c.owners.get(this)
|
|
120
|
+
const result = Reflect.apply(original, this, args) as Attr | null
|
|
121
|
+
const attr = added ? args[0] : result
|
|
122
|
+
const item = owner ?? (attr instanceof win.Attr ? attr.ownerElement : null)
|
|
123
|
+
changedNode(c, item, attr, added, result)
|
|
124
|
+
return result
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function hookAttributeNodes(c: Retained) {
|
|
130
|
+
const names = ['setAttributeNode', 'setAttributeNodeNS', 'removeAttributeNode']
|
|
131
|
+
wrapMethods(
|
|
132
|
+
c,
|
|
133
|
+
c.win.Element.prototype,
|
|
134
|
+
names,
|
|
135
|
+
(method, original) =>
|
|
136
|
+
function (this: Element, ...args: unknown[]) {
|
|
137
|
+
const result = Reflect.apply(original, this, args) as Attr | null
|
|
138
|
+
const added = method !== 'removeAttributeNode'
|
|
139
|
+
changedNode(c, this, added ? args[0] : result, added, result)
|
|
140
|
+
return result
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function hookAttributeMethods(c: Retained) {
|
|
146
|
+
const names = ['setAttribute', 'removeAttribute', 'setAttributeNS', 'removeAttributeNS']
|
|
147
|
+
wrapMethods(
|
|
148
|
+
c,
|
|
149
|
+
c.win.Element.prototype,
|
|
150
|
+
names,
|
|
151
|
+
(name, original) =>
|
|
152
|
+
function (this: Element, ...args: unknown[]) {
|
|
153
|
+
const namespaced = name.endsWith('NS')
|
|
154
|
+
const key = String(args[namespaced ? 1 : 0])
|
|
155
|
+
const attr = key.toLowerCase()
|
|
156
|
+
const local = !namespaced || args[0] === null || args[0] === ''
|
|
157
|
+
if (!c.stopped && local && c.root.contains(this) && relevant(this, attr)) {
|
|
158
|
+
const raw = name.startsWith('remove') ? null : String(args[namespaced ? 2 : 1])
|
|
159
|
+
const value = pin(c, this, attr, raw, true)
|
|
160
|
+
if (value === null) return Reflect.apply(c.nativeRemove, this, [key])
|
|
161
|
+
return Reflect.apply(c.nativeSet, this, [key, value])
|
|
162
|
+
}
|
|
163
|
+
return Reflect.apply(original, this, args)
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function hookReflected(c: Retained) {
|
|
169
|
+
const { win } = c
|
|
170
|
+
for (const [prototype, property, attr] of [
|
|
171
|
+
[win.HTMLAnchorElement.prototype, 'href', 'href'],
|
|
172
|
+
[win.HTMLFormElement.prototype, 'action', 'action'],
|
|
173
|
+
[win.HTMLButtonElement.prototype, 'formAction', 'formaction'],
|
|
174
|
+
[win.HTMLInputElement.prototype, 'formAction', 'formaction'],
|
|
175
|
+
] as const) {
|
|
176
|
+
install(
|
|
177
|
+
c,
|
|
178
|
+
prototype,
|
|
179
|
+
property,
|
|
180
|
+
(descriptor) => ({
|
|
181
|
+
...descriptor,
|
|
182
|
+
set(this: Element, value: string) {
|
|
183
|
+
const next = !c.stopped && c.root.contains(this) ? pin(c, this, attr, value, true) : value
|
|
184
|
+
// oxlint-disable-next-line typescript/unbound-method -- the native method is kept to be called with .call()
|
|
185
|
+
Reflect.apply(descriptor.set!, this, [next])
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
false,
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function hookSubmit(c: Retained) {
|
|
194
|
+
wrapMethods(
|
|
195
|
+
c,
|
|
196
|
+
c.win.HTMLFormElement.prototype,
|
|
197
|
+
['submit', 'requestSubmit'],
|
|
198
|
+
(_, original) =>
|
|
199
|
+
function (this: HTMLFormElement, ...args: unknown[]) {
|
|
200
|
+
if (!c.stopped && c.root.contains(this)) {
|
|
201
|
+
patchItem(c, this)
|
|
202
|
+
if (args[0] instanceof Element && c.root.contains(args[0])) patchItem(c, args[0])
|
|
203
|
+
}
|
|
204
|
+
return Reflect.apply(original, this, args)
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function observe(c: Retained, doc: Document) {
|
|
210
|
+
const { root } = c
|
|
211
|
+
const activate = (event: Event) => {
|
|
212
|
+
for (const item of event.composedPath())
|
|
213
|
+
if (item instanceof Element && root.contains(item)) patchItem(c, item)
|
|
214
|
+
if ('submitter' in event) {
|
|
215
|
+
const submitter = (event as SubmitEvent).submitter
|
|
216
|
+
if (submitter && root.contains(submitter)) patchItem(c, submitter)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const type of EVENTS) doc.addEventListener(type, activate, true)
|
|
220
|
+
for (const type of AFTER) doc.addEventListener(type, activate)
|
|
221
|
+
const observer = new MutationObserver((records) => {
|
|
222
|
+
for (const record of records) {
|
|
223
|
+
if (
|
|
224
|
+
record.type === 'attributes' &&
|
|
225
|
+
record.target instanceof Element &&
|
|
226
|
+
root.contains(record.target)
|
|
227
|
+
)
|
|
228
|
+
patchItem(c, record.target)
|
|
229
|
+
for (const node of record.addedNodes) {
|
|
230
|
+
if (!(node instanceof Element) || !root.contains(node)) continue
|
|
231
|
+
if (node.matches(SELECTOR)) patchItem(c, node)
|
|
232
|
+
scan(c, node)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
observer.observe(root, {
|
|
237
|
+
subtree: true,
|
|
238
|
+
childList: true,
|
|
239
|
+
attributes: true,
|
|
240
|
+
attributeFilter: ['href', 'action', 'formaction'],
|
|
241
|
+
})
|
|
242
|
+
return () => {
|
|
243
|
+
observer.disconnect()
|
|
244
|
+
for (const type of EVENTS) doc.removeEventListener(type, activate, true)
|
|
245
|
+
for (const type of AFTER) doc.removeEventListener(type, activate)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Keep retained controls on their committed URL without changing the document base. */
|
|
250
|
+
export function retainSourceContext(
|
|
251
|
+
doc: Document,
|
|
252
|
+
source: string,
|
|
253
|
+
base: string,
|
|
254
|
+
): SourceContext | null {
|
|
255
|
+
const root = doc.getElementById('app') ?? doc.body
|
|
256
|
+
const win = doc.defaultView
|
|
257
|
+
if (!root || !win || active.has(doc)) return null
|
|
258
|
+
const c: Retained = {
|
|
259
|
+
root,
|
|
260
|
+
win,
|
|
261
|
+
source,
|
|
262
|
+
base,
|
|
263
|
+
saved: new Map(),
|
|
264
|
+
owners: new WeakMap(),
|
|
265
|
+
// oxlint-disable-next-line typescript/unbound-method -- the native method is kept to be called with .call()
|
|
266
|
+
nativeSet: win.Element.prototype.setAttribute,
|
|
267
|
+
// oxlint-disable-next-line typescript/unbound-method -- the native method is kept to be called with .call()
|
|
268
|
+
nativeRemove: win.Element.prototype.removeAttribute,
|
|
269
|
+
// oxlint-disable-next-line typescript/unbound-method -- the native setter is kept to be called with .call()
|
|
270
|
+
attrValue: Object.getOwnPropertyDescriptor(win.Attr.prototype, 'value')?.set,
|
|
271
|
+
stopped: false,
|
|
272
|
+
wrappers: [],
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
hookAttributes(c)
|
|
276
|
+
hookValues(c)
|
|
277
|
+
hookNamedNodeMap(c)
|
|
278
|
+
hookAttributeNodes(c)
|
|
279
|
+
hookAttributeMethods(c)
|
|
280
|
+
hookReflected(c)
|
|
281
|
+
hookSubmit(c)
|
|
282
|
+
} catch {
|
|
283
|
+
restore(c)
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
286
|
+
active.add(doc)
|
|
287
|
+
scan(c, root)
|
|
288
|
+
const unobserve = observe(c, doc)
|
|
289
|
+
const stop = () => {
|
|
290
|
+
if (c.stopped) return
|
|
291
|
+
c.stopped = true
|
|
292
|
+
active.delete(doc)
|
|
293
|
+
unobserve()
|
|
294
|
+
restore(c)
|
|
295
|
+
}
|
|
296
|
+
let released = false
|
|
297
|
+
return {
|
|
298
|
+
stop,
|
|
299
|
+
release() {
|
|
300
|
+
if (released) return
|
|
301
|
+
released = true
|
|
302
|
+
stop()
|
|
303
|
+
for (const [item, records] of c.saved)
|
|
304
|
+
for (const [name, action] of records) {
|
|
305
|
+
if (item.getAttribute(name) !== action.installed) continue
|
|
306
|
+
if (action.original === null) item.removeAttribute(name)
|
|
307
|
+
else item.setAttribute(name, action.original)
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
}
|