@weft-js/start 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/README.md +36 -3
  2. package/crate/Cargo.lock +1347 -0
  3. package/crate/Cargo.toml +72 -0
  4. package/crate/rust-toolchain.toml +3 -0
  5. package/crate/src/build/emit.rs +103 -0
  6. package/crate/src/build.rs +367 -0
  7. package/crate/src/ctx.rs +186 -0
  8. package/crate/src/envelope.rs +163 -0
  9. package/crate/src/functions.rs +173 -0
  10. package/crate/src/lib.rs +118 -0
  11. package/crate/src/native.rs +212 -0
  12. package/crate/src/otel.rs +62 -0
  13. package/crate/src/server.rs +203 -0
  14. package/package.json +46 -37
  15. package/src/app.mjs +51 -10
  16. package/src/asset-receipt.d.mts +13 -0
  17. package/src/asset-receipt.mjs +61 -0
  18. package/src/auth/better-auth.ts +215 -17
  19. package/src/auth/drizzle.ts +1 -1
  20. package/src/auth.ts +21 -6
  21. package/src/boot.ts +19 -8
  22. package/src/bus/postgres.ts +32 -19
  23. package/src/bus.ts +7 -4
  24. package/src/{capture.mjs → capture.ts} +34 -1
  25. package/src/changes-sql.d.mts +8 -2
  26. package/src/changes-sql.mjs +26 -10
  27. package/src/changes.ts +165 -66
  28. package/src/channel/connections.ts +216 -0
  29. package/src/channel/socket.ts +396 -0
  30. package/src/channel-fake.ts +19 -4
  31. package/src/channel.ts +124 -490
  32. package/src/client.tsx +9 -2
  33. package/src/contract/describe.mjs +231 -0
  34. package/src/contract.mjs +146 -155
  35. package/src/data/cache.ts +354 -0
  36. package/src/data/definitions.ts +94 -0
  37. package/src/data/draft.ts +104 -0
  38. package/src/data/foundation.ts +95 -0
  39. package/src/data/live-api.ts +22 -0
  40. package/src/data/mutation.ts +138 -0
  41. package/src/data/primitives.ts +135 -0
  42. package/src/data/query.ts +106 -0
  43. package/src/data/transport.ts +121 -0
  44. package/src/data/types.ts +303 -0
  45. package/src/data.ts +69 -894
  46. package/src/db/bun-sqlite.d.ts +4 -1
  47. package/src/db/changes-mysql.ts +61 -21
  48. package/src/db/changes-postgres.ts +197 -118
  49. package/src/db/changes-sqlite.ts +131 -64
  50. package/src/db/core.ts +38 -13
  51. package/src/db/index.ts +20 -4
  52. package/src/db/mysql.ts +22 -6
  53. package/src/db/postgres.ts +49 -13
  54. package/src/db/sqlite.ts +53 -27
  55. package/src/derive.d.mts +4 -1
  56. package/src/derive.mjs +136 -39
  57. package/src/dev-runtime.mjs +8 -4
  58. package/src/exec/errors.ts +57 -0
  59. package/src/exec/landing.ts +66 -0
  60. package/src/exec/scope.ts +174 -0
  61. package/src/exec/touch.ts +80 -0
  62. package/src/exec/validation.ts +176 -0
  63. package/src/exec.ts +191 -341
  64. package/src/extension.ts +8 -2
  65. package/src/first-paint.ts +26 -9
  66. package/src/form/value.ts +15 -0
  67. package/src/form.tsx +132 -43
  68. package/src/hub/channels.ts +102 -0
  69. package/src/hub/connections.ts +147 -0
  70. package/src/hub/delivery.ts +62 -0
  71. package/src/hub/feed.ts +168 -0
  72. package/src/hub/presence.ts +64 -0
  73. package/src/hub/route.ts +158 -0
  74. package/src/hub/subscriptions.ts +212 -0
  75. package/src/hub/topic.ts +98 -0
  76. package/src/hub/tracked.ts +12 -0
  77. package/src/hub/types.ts +190 -0
  78. package/src/hub/view.ts +147 -0
  79. package/src/hub.ts +132 -758
  80. package/src/index.ts +84 -10
  81. package/src/inline.mjs +356 -0
  82. package/src/inspect/compare.mjs +162 -0
  83. package/src/inspect/routes.mjs +154 -0
  84. package/src/inspect/samples.mjs +59 -0
  85. package/src/inspect/url.mjs +2 -0
  86. package/src/inspect.mjs +146 -187
  87. package/src/link/election.ts +178 -0
  88. package/src/link/lines.ts +27 -0
  89. package/src/link.ts +97 -168
  90. package/src/list-drizzle.ts +104 -18
  91. package/src/list.ts +127 -36
  92. package/src/listen.ts +32 -6
  93. package/src/live/api.ts +18 -0
  94. package/src/live/backoff.ts +6 -0
  95. package/src/live/frames.ts +42 -0
  96. package/src/live/read.ts +77 -0
  97. package/src/live/sub-types.ts +24 -0
  98. package/src/live/transport.ts +86 -0
  99. package/src/live/types.ts +52 -0
  100. package/src/live-fake.ts +20 -5
  101. package/src/live-target.mjs +113 -35
  102. package/src/live.ts +116 -225
  103. package/src/media.ts +8 -1
  104. package/src/migrations.mjs +14 -6
  105. package/src/mux.ts +161 -64
  106. package/src/og/DefaultCard.tsx +40 -9
  107. package/src/og/fonts.mjs +16 -8
  108. package/src/og/images.mjs +43 -22
  109. package/src/og/index.mjs +119 -43
  110. package/src/og/native.mjs +11 -4
  111. package/src/range.mjs +15 -3
  112. package/src/router.ts +56 -6
  113. package/src/routes.mjs +73 -22
  114. package/src/run/admission.ts +80 -0
  115. package/src/run/boot.tsx +115 -0
  116. package/src/run/cache-presentation.ts +67 -0
  117. package/src/run/cache.ts +254 -0
  118. package/src/run/helpers.ts +197 -0
  119. package/src/run/listeners.ts +155 -0
  120. package/src/run/navigation.ts +382 -0
  121. package/src/run/page-skeleton.tsx +35 -0
  122. package/src/run/prefetch.ts +128 -0
  123. package/src/run/retain-view.ts +18 -0
  124. package/src/run/shape-preparation.ts +97 -0
  125. package/src/run/shape-styles.ts +72 -0
  126. package/src/run/source-context/pin.ts +124 -0
  127. package/src/run/source-context.ts +311 -0
  128. package/src/run.tsx +50 -174
  129. package/src/rust/common.ts +163 -0
  130. package/src/rust/native.ts +282 -0
  131. package/src/rust.ts +103 -229
  132. package/src/server/card.tsx +65 -0
  133. package/src/server/compression.ts +115 -0
  134. package/src/server/core.ts +70 -0
  135. package/src/server/dispatch.ts +70 -0
  136. package/src/server/form.ts +85 -0
  137. package/src/server/functions.ts +129 -0
  138. package/src/server/handler.ts +39 -0
  139. package/src/server/head.ts +96 -0
  140. package/src/server/media.ts +60 -0
  141. package/src/server/navigation-cache.ts +80 -0
  142. package/src/server/navigation.tsx +85 -0
  143. package/src/server/page-cold.ts +95 -0
  144. package/src/server/page-stream.tsx +181 -0
  145. package/src/server/page-types.ts +51 -0
  146. package/src/server/page-warm.ts +130 -0
  147. package/src/server/page.ts +69 -0
  148. package/src/server/payload.ts +89 -0
  149. package/src/server/prerender.ts +60 -0
  150. package/src/server/preview.tsx +67 -0
  151. package/src/server/render.ts +167 -0
  152. package/src/server/request.ts +98 -0
  153. package/src/server/resolver.ts +228 -0
  154. package/src/server/routes.ts +150 -0
  155. package/src/server/services.ts +69 -0
  156. package/src/server/text.ts +29 -0
  157. package/src/server/types.ts +160 -0
  158. package/src/server.tsx +82 -964
  159. package/src/shape.mjs +30 -6
  160. package/src/share/display.ts +50 -0
  161. package/src/share/network.ts +119 -0
  162. package/src/share/receive.ts +70 -0
  163. package/src/share/state.ts +30 -0
  164. package/src/share/tab-queue.ts +28 -0
  165. package/src/share-server.ts +128 -43
  166. package/src/share.ts +103 -209
  167. package/src/sheets.mjs +30 -18
  168. package/src/sidecar/build.mjs +170 -0
  169. package/src/sidecar/cargo.mjs +164 -0
  170. package/src/sidecar/common.mjs +17 -0
  171. package/src/sidecar/dev-build.mjs +91 -0
  172. package/src/sidecar/dev-watch.mjs +88 -0
  173. package/src/sidecar/manifest.mjs +19 -0
  174. package/src/sidecar/process.mjs +58 -0
  175. package/src/sidecar/refresh.mjs +19 -0
  176. package/src/sidecar.mjs +75 -416
  177. package/src/socket/check.ts +50 -0
  178. package/src/socket/deliver.ts +16 -0
  179. package/src/socket/publish.ts +56 -0
  180. package/src/socket/rooms.ts +41 -0
  181. package/src/socket/route.ts +79 -0
  182. package/src/socket/shared.ts +26 -0
  183. package/src/socket/types.ts +48 -0
  184. package/src/socket.ts +205 -194
  185. package/src/stream.ts +10 -1
  186. package/src/verify/app.ts +39 -0
  187. package/src/verify/cleanup.ts +27 -0
  188. package/src/verify/database.ts +11 -0
  189. package/src/verify/live.ts +63 -0
  190. package/src/verify/notify.ts +35 -0
  191. package/src/verify/skip.ts +19 -0
  192. package/src/verify/support.ts +149 -0
  193. package/src/verify/timing.ts +15 -0
  194. package/src/verify/trigger.ts +14 -0
  195. package/src/verify.ts +77 -176
  196. package/src/view.tsx +96 -11
  197. package/src/vite/asset-bytes.mjs +95 -0
  198. package/src/vite/assets.mjs +117 -0
  199. package/src/vite/config-hooks.mjs +166 -0
  200. package/src/vite/dev-hooks.mjs +103 -0
  201. package/src/vite/dev.mjs +102 -0
  202. package/src/vite/entries.mjs +145 -0
  203. package/src/vite/module-hooks.mjs +288 -0
  204. package/src/vite/route-modules.mjs +289 -0
  205. package/src/vite/server-functions.mjs +222 -0
  206. package/src/vite/shape-entry-css.mjs +187 -0
  207. package/src/vite/shape-override.mjs +226 -0
  208. package/src/vite/shared.mjs +51 -0
  209. package/src/vite/sheets.mjs +92 -0
  210. package/src/vite/state.mjs +89 -0
  211. package/src/vite/syntax.mjs +139 -0
  212. package/src/vite.mjs +152 -853
  213. package/src/capture.d.mts +0 -1
@@ -0,0 +1,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
+ }