@weft-js/start 0.1.1 → 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 +38 -8
  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 +120 -492
  32. package/src/client.tsx +9 -2
  33. package/src/contract/describe.mjs +231 -0
  34. package/src/contract.mjs +144 -157
  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 +90 -166
  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 +112 -37
  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 -178
  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 -971
  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
package/src/view.tsx CHANGED
@@ -5,6 +5,20 @@
5
5
 
6
6
  import { untrack } from '@weft-js/core'
7
7
  import { active, level, type RouteState } from './router'
8
+ import type { PreparedRouteShape } from './run/shape-preparation'
9
+ import { retainRouteView } from './run/retain-view'
10
+
11
+ function ShapeTree(props: { shapes: PreparedRouteShape[]; depth: number }): unknown {
12
+ // A skeleton is a snapshot: the outlet swaps the whole tree when its shapes change.
13
+ const item = untrack(() => props.shapes[props.depth])
14
+ if (!item) return null
15
+ const Shape = item.component as (props: { children: unknown }) => unknown
16
+ return (
17
+ <Shape>
18
+ <ShapeTree shapes={props.shapes} depth={props.depth + 1} />
19
+ </Shape>
20
+ )
21
+ }
8
22
 
9
23
  // The route state travels as a prop, not a context: a level's view runs later, in the hole that shows it, under that
10
24
  // hole's owner. `active.route` is the state of the render in progress (a server pass is synchronous; the client has one).
@@ -15,33 +29,104 @@ export function App(props: { route: RouteState }) {
15
29
 
16
30
  export function Level(props: { route: RouteState; depth: number }) {
17
31
  const route = untrack(() => props.route)
18
- const lv = level(route, untrack(() => props.depth))
32
+ const lv = level(
33
+ route,
34
+ untrack(() => props.depth),
35
+ )
19
36
  const next = untrack(() => props.depth) + 1
20
37
  const view = () => {
21
38
  const id = lv.id.value
22
39
  if (id === undefined) return null
23
40
  const html = route.mount ? lv.html.value : undefined
24
- if (html !== undefined) return route.mount!(html, untrack(() => props.depth), () => <Level route={route} depth={next} />)
25
- const Route = route.comps[id] as ((props: { params: Record<string, string>; data: unknown; children: unknown }) => unknown) | undefined
41
+ if (html !== undefined)
42
+ return route.mount!(
43
+ html,
44
+ untrack(() => props.depth),
45
+ () => <Level route={route} depth={next} />,
46
+ )
47
+ const Route = route.comps[id] as
48
+ | ((props: { params: Record<string, string>; data: unknown; children: unknown }) => unknown)
49
+ | undefined
26
50
  if (!Route) return null
27
- const body = () => <Route params={lv.params.value} data={lv.data.value}><Level route={route} depth={next} /></Route>
28
- return route.around ? route.around(untrack(() => props.depth), id, body) : body()
51
+ const body = () => (
52
+ <Route params={lv.params.value} data={lv.data.value}>
53
+ <Level route={route} depth={next} />
54
+ </Route>
55
+ )
56
+ return route.around
57
+ ? route.around(
58
+ untrack(() => props.depth),
59
+ id,
60
+ body,
61
+ )
62
+ : body()
29
63
  }
30
- return <>{view()}</>
64
+ const display = retainRouteView(view, () => {
65
+ const shape = lv.shape.value
66
+ return shape ? (
67
+ <div inert aria-hidden="true" style="display:contents">
68
+ <ShapeTree shapes={shape} depth={0} />
69
+ </div>
70
+ ) : undefined
71
+ })
72
+ return <>{display()}</>
73
+ }
74
+
75
+ /** True when `path` is `href`, or with `section`, any page under it (a top link for `/components` on `/components/modal`). */
76
+ export function isCurrent(path: string, href: string, section = false) {
77
+ if (path === href) return true
78
+ return section && href !== '/' && path.startsWith(`${href.replace(/\/$/, '')}/`)
31
79
  }
32
80
 
33
81
  /**
34
- * `<a>` with `aria-current="page"` while its path is the current one. Any same-origin `<a href>` navigates on the
35
- * client (start() listens for clicks); Link only adds the current-page state. In a static level the server writes it
36
- * and start() updates it after a navigation, so a Link needs no code there.
82
+ * `<a>` with `aria-current="page"` while its path is the current one, or with `match="section"` any page under it, as a
83
+ * site's top links want. Any same-origin `<a href>` navigates on the client (start() listens for clicks); Link only
84
+ * adds the current-page state. In a static level the server writes it and start() updates it after a navigation, so a
85
+ * Link needs no code there.
37
86
  *
38
87
  * @weft static
39
88
  */
40
- export function Link({ href, children, ...rest }: Record<string, unknown> & { href: string; class?: string; rel?: string; children?: unknown }) {
89
+ export function Link({
90
+ href,
91
+ match,
92
+ children,
93
+ ...rest
94
+ }: Record<string, unknown> & {
95
+ href: string
96
+ match?: 'page' | 'section'
97
+ class?: string
98
+ rel?: string
99
+ children?: unknown
100
+ }) {
41
101
  const route = active.route
42
102
  return (
43
- <a {...rest} href={href} aria-current={route && route.path.value.replace(/[?#].*/, '') === href ? 'page' : undefined}>
103
+ <a
104
+ {...rest}
105
+ href={href}
106
+ data-match={match === 'section' ? 'section' : undefined}
107
+ aria-current={
108
+ route && isCurrent(route.path.value.replace(/[?#].*/, ''), href, match === 'section')
109
+ ? 'page'
110
+ : undefined
111
+ }
112
+ >
44
113
  {children}
45
114
  </a>
46
115
  )
47
116
  }
117
+
118
+ /**
119
+ * The current page's path, without its search or hash: the request's on the server, and on the client the accepted
120
+ * destination from the moment a navigation starts. Reading it is reactive.
121
+ *
122
+ * Use it to keep shared chrome mounted across sections: one layout above them renders the sidebar and picks its items
123
+ * from the path, so moving between sections swaps the items and the page, and the sidebar stays. A layout per section
124
+ * would be replaced, sidebar and all, on every move between them.
125
+ *
126
+ * What reads it runs in the browser, since it changes there: a static level's HTML would be swapped whole on each move.
127
+ *
128
+ * @weft client
129
+ */
130
+ export function currentPath(): string {
131
+ return active.route?.path.value.replace(/[?#].*/, '') ?? '/'
132
+ }
@@ -0,0 +1,95 @@
1
+ const byText = (a, b) => (a < b ? -1 : a > b ? 1 : 0)
2
+
3
+ // A closure names emitted files, so callers can union routes without counting a shared file twice.
4
+ function closure(bundle, roots) {
5
+ const seen = new Set()
6
+ let complete = true
7
+ const visit = (file) => {
8
+ if (seen.has(file)) return
9
+ seen.add(file)
10
+ const item = bundle[file]
11
+ if (item?.type !== 'chunk') return
12
+ if (
13
+ !Array.isArray(item.imports) ||
14
+ !item.viteMetadata?.importedCss ||
15
+ !item.viteMetadata?.importedAssets
16
+ )
17
+ complete = false
18
+ for (const dep of item.imports ?? []) visit(dep)
19
+ for (const dep of item.viteMetadata?.importedCss ?? []) visit(dep)
20
+ for (const dep of item.viteMetadata?.importedAssets ?? []) visit(dep)
21
+ }
22
+ for (const file of roots) visit(file)
23
+ return { files: [...seen].sort(byText), complete }
24
+ }
25
+
26
+ function chunkFor(bundle, ids) {
27
+ const matches = Object.entries(bundle).filter(
28
+ ([, item]) =>
29
+ item.type === 'chunk' &&
30
+ ids.some((id) => item.facadeModuleId === id || Object.hasOwn(item.modules ?? {}, id)),
31
+ )
32
+ return matches.length === 1 ? matches[0][0] : undefined
33
+ }
34
+
35
+ /** Byte lengths of emitted static closures. Dynamic and external dependencies are not inferred. */
36
+ export function assetBytes(bundle, read, manifest, abs) {
37
+ const routes = Object.fromEntries(
38
+ [...manifest.layouts, ...manifest.routes].map((route) => {
39
+ const file = abs(route.file)
40
+ const root = chunkFor(bundle, [file, `${file}?weft-assets`])
41
+ return [route.id, root ? closure(bundle, [root]) : null]
42
+ }),
43
+ )
44
+ const eagerRoot = chunkFor(bundle, ['\0virtual:weft/routes'])
45
+ const eager = eagerRoot ? closure(bundle, [eagerRoot]) : null
46
+ const owners = new Map()
47
+ for (const entry of [...Object.values(routes), eager]) {
48
+ for (const file of entry?.files ?? []) owners.set(file, (owners.get(file) ?? 0) + 1)
49
+ }
50
+ const shared = [...owners]
51
+ .filter(([, count]) => count > 1)
52
+ .map(([file]) => file)
53
+ .sort(byText)
54
+ const sharedSet = new Set(shared)
55
+ const files = Object.fromEntries(
56
+ [...owners.keys()].sort(byText).map((file) => {
57
+ const item = bundle[file]
58
+ let bytes = null
59
+ if (item) {
60
+ try {
61
+ bytes = read(file).length
62
+ } catch {
63
+ /* not emitted or not readable */
64
+ }
65
+ }
66
+ const kind = !item
67
+ ? 'unknown'
68
+ : item.type === 'chunk'
69
+ ? 'js'
70
+ : file.endsWith('.css')
71
+ ? 'css'
72
+ : 'asset'
73
+ return [file, { bytes, kind }]
74
+ }),
75
+ )
76
+ const size = (items) =>
77
+ items.some((file) => files[file].bytes === null)
78
+ ? null
79
+ : items.reduce((sum, file) => sum + files[file].bytes, 0)
80
+ const describe = (entry) =>
81
+ entry && {
82
+ files: entry.files,
83
+ bytes: entry.complete ? size(entry.files) : null,
84
+ uniqueBytes: entry.complete ? size(entry.files.filter((file) => !sharedSet.has(file))) : null,
85
+ sharedBytes: entry.complete ? size(entry.files.filter((file) => sharedSet.has(file))) : null,
86
+ }
87
+ return {
88
+ version: 1,
89
+ scope: 'vite-static-closure',
90
+ files,
91
+ shared,
92
+ routes: Object.fromEntries(Object.entries(routes).map(([id, items]) => [id, describe(items)])),
93
+ eager: describe(eager),
94
+ }
95
+ }
@@ -0,0 +1,117 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { relative } from 'node:path'
3
+ import { parse } from './syntax.mjs'
4
+ import { isServerModule } from './server-functions.mjs'
5
+ import { NOT_ASSET, ASSETS } from './shared.mjs'
6
+
7
+ /** weft's own packages from an install: a static component's stylesheets are in them, and they ship source. */
8
+ const KIT = /\/node_modules\/@weft-js\/.*\.[jt]sx?$/
9
+
10
+ /**
11
+ * The stylesheets a kit import reaches, in import order, written straight into the static module: chained `?weft-assets`
12
+ * modules from a package with `sideEffects` would be shaken away. `names` follows a barrel only to the components named,
13
+ * so a page with one doesn't pull every component's sheet.
14
+ */
15
+ async function kitAssets(ctx, file, names, seen, words) {
16
+ const id = names ? `${file}|${names.join()}` : file
17
+ if (seen.has(id)) return []
18
+ seen.add(id)
19
+ const code = readFileSync(file, 'utf8')
20
+ const body = parse(file, code).program.body
21
+ const wanted = (name) => !names || names.includes(name)
22
+ const makes = ({ declaration: d, specifiers }) =>
23
+ d?.declarations
24
+ ? d.declarations.some((v) => v.id.type !== 'Identifier' || names.includes(v.id.name))
25
+ : names.includes(d?.id?.name) ||
26
+ specifiers.some((s) => names.includes(s.exported.name ?? s.exported.value))
27
+ // Only a file that makes a name itself brings its own imports: one that passes it on is a barrel.
28
+ const own =
29
+ !names ||
30
+ body.some(
31
+ (n) =>
32
+ n.type === 'ExportDefaultDeclaration' ||
33
+ (n.type === 'ExportNamedDeclaration' && !n.source && makes(n)),
34
+ )
35
+ if (own) words?.add(code)
36
+ const out = []
37
+ for (const n of body) {
38
+ if (!n.source || n.importKind === 'type' || n.exportKind === 'type') continue
39
+ const all = n.type === 'ExportAllDeclaration'
40
+ const space = all && n.exported && (n.exported.name ?? n.exported.value) // `export * as K`: K is the whole module
41
+ const passed =
42
+ n.type === 'ExportNamedDeclaration' &&
43
+ n.specifiers
44
+ .filter((s) => s.exportKind !== 'type' && wanted(s.exported.name ?? s.exported.value))
45
+ .map((s) => s.local.name ?? s.local.value)
46
+ // A bare import runs whatever the importer wants from the file.
47
+ if (
48
+ n.type === 'ImportDeclaration'
49
+ ? !own && n.specifiers.length
50
+ : passed
51
+ ? !passed.length
52
+ : !all || (space && !wanted(space))
53
+ )
54
+ continue
55
+ if (/^@weft-js\/(?:core|start)($|\/)/.test(n.source.value)) continue
56
+ const target = (await ctx.resolve(n.source.value, file, { skipSelf: true }))?.id
57
+ if (!target) continue
58
+ const bare = target.replace(/\?.*$/, '')
59
+ if (!NOT_ASSET.test(bare))
60
+ out.push(`import ${JSON.stringify(target.startsWith('\0') ? n.source.value : bare)}`)
61
+ else if (KIT.test(bare))
62
+ out.push(
63
+ ...(await kitAssets(
64
+ ctx,
65
+ bare,
66
+ passed || (space ? undefined : all ? names : importedNames(n)),
67
+ seen,
68
+ words,
69
+ )),
70
+ )
71
+ }
72
+ return [...new Set(out)]
73
+ }
74
+
75
+ /** The value names an import takes, or undefined for the whole module (`import 'x'`, `import * as x`). */
76
+ const importedNames = (n) => {
77
+ const names = (n.specifiers ?? [])
78
+ .filter((s) => s.type === 'ImportSpecifier' && s.importKind !== 'type')
79
+ .map((s) => (s.imported ?? s.local).name ?? (s.imported ?? s.local).value)
80
+ return names.length && n.specifiers.every((s) => s.type === 'ImportSpecifier') ? names : undefined
81
+ }
82
+
83
+ /**
84
+ * A static module in the client build: what it imports that isn't code (stylesheets, images), and the same of the
85
+ * modules it imports. Its class names go to the purge, which only sees client files.
86
+ */
87
+ export async function assetsOnly(ctx, code, path, root, words) {
88
+ words?.add(code)
89
+ const ast = parse(path, code)
90
+ const out = []
91
+ const seen = new Set()
92
+ for (const n of ast.program.body) {
93
+ if (
94
+ !/^(ImportDeclaration|ExportNamedDeclaration|ExportAllDeclaration)$/.test(n.type) ||
95
+ !n.source ||
96
+ n.importKind === 'type' ||
97
+ n.exportKind === 'type'
98
+ )
99
+ continue
100
+ const spec = n.source.value
101
+ if (/^@weft-js\/(?:core|start)($|\/)/.test(spec)) continue
102
+ const file = (await ctx.resolve(spec, path, { skipSelf: true }))?.id
103
+ if (!file) continue
104
+ const bare = file.replace(/\?.*$/, '')
105
+ if (!NOT_ASSET.test(bare)) out.push(`import ${JSON.stringify(spec)}`)
106
+ else if (KIT.test(bare))
107
+ out.push(...(await kitAssets(ctx, bare, importedNames(n), seen, words)))
108
+ else if (
109
+ !bare.includes('/node_modules/') &&
110
+ !isServerModule(relative(root, bare).split('\\').join('/'))
111
+ )
112
+ out.push(`import ${JSON.stringify(bare + ASSETS)}`)
113
+ }
114
+ if (code.includes('@weft-js/core/style')) out.push(`import 'virtual:weft/groups.css'`)
115
+ out.push('export default null') // a chunk of only CSS is dropped, and the server looks this one up
116
+ return out.join('\n') + '\n'
117
+ }
@@ -0,0 +1,166 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync, realpathSync, writeFileSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { isAbsolute, join, relative, resolve, sep, win32 } from 'node:path'
5
+ import { searchForWorkspaceRoot } from 'vite'
6
+ import { INLINE_CSS_MAX } from '../inline.mjs'
7
+ import { packageDirs } from './dev.mjs'
8
+ import { assetBytes } from './asset-bytes.mjs'
9
+ import { combineSheets, sheetPieces, inlineSheets } from './sheets.mjs'
10
+ import { here, V, KIT_CHUNKS, INLINE_SHEETS, SHEET_PIECES, ROUTE_ASSET_BYTES } from './shared.mjs'
11
+
12
+ export const configHook = (s) =>
13
+ function (config, env) {
14
+ s.root = config.root ? resolve(config.root) : s.root
15
+ s.dev = env.command === 'serve'
16
+ s.scan()
17
+ return {
18
+ environments: {
19
+ client: {
20
+ build: {
21
+ rollupOptions: {
22
+ input: s.clientEntry(),
23
+ output: { advancedChunks: { groups: KIT_CHUNKS } },
24
+ },
25
+ },
26
+ },
27
+ ssr: { build: { rollupOptions: { input: V.server } } },
28
+ },
29
+ // The router-core server bundle and Takumi's addon stay external to the SSR graph; Nitro traces them.
30
+ ssr: {
31
+ noExternal: ['@weft-js/start', '@weft-js/warp', '@weft-js/warp-icons', '@weft-js/core'],
32
+ external: ['@takumi-rs/core'],
33
+ },
34
+ optimizeDeps: {
35
+ exclude: ['@weft-js/start', '@weft-js/warp', '@weft-js/warp-icons', '@weft-js/core'],
36
+ }, // they ship TSX for the weft plugin
37
+ // The runtime files of @weft-js/start, weft and Nitro are served from wherever they're installed (a linked package
38
+ // sits outside the app's root).
39
+ server: {
40
+ fs: { allow: [searchForWorkspaceRoot(s.root), join(here, '..'), ...packageDirs(s.root)] },
41
+ },
42
+ // One copy of the router: the app's route files and the generated entries import the same module.
43
+ resolve: {
44
+ alias: [
45
+ { find: /^@weft-js\/start$/, replacement: join(here, 'index.ts') },
46
+ { find: /^@weft-js\/start\/(client|server)$/, replacement: join(here, '$1.tsx') },
47
+ { find: /^@weft-js\/start\/media$/, replacement: join(here, 'media.ts') },
48
+ ],
49
+ },
50
+ }
51
+ }
52
+
53
+ function emittedReader(dir) {
54
+ const root = realpathSync(dir)
55
+ const inside = (path) => {
56
+ const rel = relative(root, path)
57
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
58
+ }
59
+ return (file) => {
60
+ if (
61
+ typeof file !== 'string' ||
62
+ !file ||
63
+ isAbsolute(file) ||
64
+ win32.parse(file).root ||
65
+ file.includes('\\') ||
66
+ file.split('/').includes('..')
67
+ )
68
+ throw new Error('Invalid asset path')
69
+ const path = resolve(root, file)
70
+ if (!inside(path)) throw new Error('Asset outside output')
71
+ const real = realpathSync(path)
72
+ if (!inside(real)) throw new Error('Asset outside output')
73
+ return readFileSync(real)
74
+ }
75
+ }
76
+
77
+ export const writeBundle = (s) => ({
78
+ order: 'post',
79
+ async handler(out, bundle) {
80
+ if (!s.dev && this.environment?.config.consumer === 'client' && out.dir) {
81
+ const name = 'weft-route-assets.json'
82
+ if (bundle[name]) throw new Error(`Cannot overwrite emitted ${name}`)
83
+ const bytes = assetBytes(bundle, emittedReader(out.dir), s.manifest ?? s.scan(), s.abs)
84
+ bytes.build = s.build || null
85
+ writeFileSync(join(out.dir, name), JSON.stringify(bytes))
86
+ s.routeAssetBytes = bytes
87
+ }
88
+ if (
89
+ s.dev ||
90
+ this.environment?.config.consumer !== 'client' ||
91
+ s.options.site?.criticalCss === false ||
92
+ s.options.site?.inlineCss === false
93
+ )
94
+ return
95
+ s.sheetPlan = combineSheets(
96
+ out.dir,
97
+ bundle,
98
+ s.manifest ?? s.scan(),
99
+ s.abs,
100
+ (await s.hydration(this))?.islands ?? [],
101
+ s.options.site?.base ?? '/',
102
+ )
103
+ },
104
+ })
105
+
106
+ export const renderChunk = (s) => ({
107
+ order: 'post',
108
+ handler(code) {
109
+ const receiptToken = new RegExp(`["'\`]${ROUTE_ASSET_BYTES}["'\`]`)
110
+ const hasReceipt = receiptToken.test(code)
111
+ if (hasReceipt) {
112
+ let receipt = null
113
+ try {
114
+ const top = this.environment.getTopLevelConfig()
115
+ const client = resolve(s.root, top.environments.client.build.outDir)
116
+ const saved = JSON.parse(readFileSync(join(client, 'weft-route-assets.json'), 'utf8'))
117
+ if (s.build && saved.build === s.build) receipt = saved
118
+ } catch {
119
+ /* no matching client receipt */
120
+ }
121
+ code = code.replace(receiptToken, () => JSON.stringify(receipt))
122
+ }
123
+ const token = new RegExp(`["'\`]${INLINE_SHEETS}["'\`]`)
124
+ if (!token.test(code)) return hasReceipt ? { code, map: null } : null
125
+ const top = this.environment.getTopLevelConfig()
126
+ const client = resolve(s.root, top.environments.client.build.outDir)
127
+ code = code.replace(new RegExp(`["'\`]${SHEET_PIECES}["'\`]`), () =>
128
+ JSON.stringify(
129
+ s.sheetPlan &&
130
+ sheetPieces(client, top.base ?? '/', s.sheetPlan, s.options.site?.criticalKeep, s.root),
131
+ ),
132
+ )
133
+ // With a first-paint table the server reads none of these; without, a page too big to carry whole is split (`pageStyles`).
134
+ const sheets =
135
+ s.options.site?.inlineCss === false
136
+ ? {}
137
+ : inlineSheets(
138
+ client,
139
+ top.base ?? '/',
140
+ s.options.site?.criticalCss === false ? Infinity : INLINE_CSS_MAX,
141
+ )
142
+ return { code: code.replace(token, () => JSON.stringify(sheets)), map: null }
143
+ },
144
+ })
145
+
146
+ export const configResolved = (s) =>
147
+ function (config) {
148
+ // Each build is its own version, unless it's pinned: instances built apart that serve one deploy, or a
149
+ // reproducible build (SOURCE_DATE_EPOCH), have to agree on it.
150
+ s.build =
151
+ process.env.WEFT_BUILD_ID ||
152
+ createHash('sha256')
153
+ .update(process.env.SOURCE_DATE_EPOCH || String(Date.now()))
154
+ .digest('hex')
155
+ .slice(0, 8)
156
+ if (s.dev) s.build = 'dev'
157
+ s.root = config.root
158
+ const main = config.plugins.find((p) => p.name === 'weft')
159
+ s.words = config.plugins.find((p) => p.name === 'weft:purge')?.api
160
+ s.words?.add('data-weft styled') // gate.css reveals a mark a static page's HTML may hold alone
161
+ if (main?.api) {
162
+ s.compile = createRequire(join(here, 'vite.mjs'))('@weft-js/core/compiler').transform
163
+ main.api.islands = async (ctx, file) =>
164
+ (await s.hydration(ctx))?.files.get(file.replace(/\?.*$/, ''))
165
+ }
166
+ }
@@ -0,0 +1,103 @@
1
+ import { existsSync, readFileSync, watch } from 'node:fs'
2
+ import { join, relative } from 'node:path'
3
+ import { waitingForAFix, reaches, refresh } from './dev.mjs'
4
+ import { serverExports } from './syntax.mjs'
5
+ import { isServerModule } from './server-functions.mjs'
6
+ import { SHEET, ASSETS, V } from './shared.mjs'
7
+
8
+ export const configureServer = (s) =>
9
+ function (server) {
10
+ s.vite = server
11
+ // A `<link>` to a virtual sheet (`weft:fonts`): Vite only serves those as modules.
12
+ const sheet = async (req, res, next) => {
13
+ const css = await server.environments.client.pluginContainer
14
+ .load(decodeURIComponent(req.url.slice(1)))
15
+ .catch(() => undefined)
16
+ if (css == null) return next()
17
+ res.setHeader('content-type', 'text/css')
18
+ res.end(typeof css === 'string' ? css : css.code)
19
+ }
20
+ server.middlewares.use(SHEET, (req, res, next) => void sheet(req, res, next))
21
+ server.middlewares.use(waitingForAFix)
22
+ // A route file added or removed changes the virtual modules; edits to existing ones are ordinary HMR/reloads.
23
+ const dir = join(s.root, 'src')
24
+ if (!existsSync(dir)) return
25
+ const routesDir = relative(dir, join(s.root, s.manifest.dir)).split('\\').join('/')
26
+ const shape = () =>
27
+ JSON.stringify([
28
+ s.manifest.routes.map((r) => r.file),
29
+ s.manifest.layouts.map((l) => l.file),
30
+ s.serverModules,
31
+ ])
32
+ const changed = (file) => {
33
+ const rel = file.split('\\').join('/')
34
+ if (!rel.startsWith(routesDir + '/') && !isServerModule('src/' + rel)) return
35
+ // Saving a file can count as a rename, depending on the platform and the editor.
36
+ const before = shape()
37
+ s.scan()
38
+ if (shape() === before) return
39
+ for (const env of Object.values(server.environments)) {
40
+ for (const v of [V.routes, V.fns, V.context]) {
41
+ const m = env.moduleGraph.getModuleById('\0' + v)
42
+ if (m) env.moduleGraph.invalidateModule(m)
43
+ }
44
+ }
45
+ server.config.logger.info(
46
+ `weft: full reload: ${rel} was added or removed, so the routes changed`,
47
+ { timestamp: true },
48
+ )
49
+ server.ws.send({ type: 'full-reload' })
50
+ }
51
+ const w = watch(dir, { recursive: true }, (event, file) => {
52
+ if (event === 'rename' && file) changed(file)
53
+ })
54
+ // Not every runtime's recursive watch reports a new file, and Vite's watcher does.
55
+ for (const event of ['add', 'unlink'])
56
+ server.watcher.on(event, (file) => {
57
+ if (file.startsWith(dir)) changed(relative(dir, file))
58
+ })
59
+ // Close with the server (middleware mode, as `weft routes --meta` runs it, has no httpServer to listen on).
60
+ const close = server.close.bind(server)
61
+ server.close = () => (w.close(), close())
62
+ }
63
+
64
+ export const hotUpdate = (s) =>
65
+ async function ({ file, modules }) {
66
+ const rel = relative(s.root, file).split('\\').join('/')
67
+ if (this.environment.config.consumer === 'server') {
68
+ // An edit can add or drop a stylesheet import: the next server load of the routes walks the graph again.
69
+ const m = this.environment.moduleGraph.getModuleById('\0' + V.routes)
70
+ if (m) this.environment.moduleGraph.invalidateModule(m)
71
+ // A static component or a server helper is in no browser's module graph, so nothing else would tell the tab.
72
+ if (
73
+ !s.vite?.environments.client.moduleGraph.getModulesByFile(file)?.size &&
74
+ this.environment.moduleGraph.getModulesByFile(file)?.size
75
+ )
76
+ refresh(s.vite, rel)
77
+ return
78
+ }
79
+ const route =
80
+ s.manifest.routes.some((r) => r.file === rel) ||
81
+ s.manifest.layouts.some((l) => l.file === rel)
82
+ if (route) {
83
+ const part = serverExports(readFileSync(file, 'utf8'), file)
84
+ // A level the server renders has no module in the page, and its `load` and `meta` are on the server too.
85
+ refresh(s.vite, rel, s.loaders.get(file) !== part, false)
86
+ s.loaders.set(file, part)
87
+ }
88
+ if (isServerModule(rel) && s.stubs.has(file) && modules.length) {
89
+ const before = s.stubs.get(file)
90
+ await Promise.all(
91
+ modules.map((m) => this.environment.transformRequest(m.url).catch(() => undefined)),
92
+ )
93
+ if (s.stubs.get(file) === before) return (refresh(s.vite, rel), [])
94
+ }
95
+ // A static route's stylesheet list: nothing runs it, so nothing has to take the update.
96
+ const live = modules.filter((m) => !m.url.endsWith(ASSETS))
97
+ if (live.some((m) => !reaches(m)))
98
+ this.environment.logger.info(
99
+ `weft: full reload: ${rel} exports more than components and nothing that imports it can take the update`,
100
+ { timestamp: true },
101
+ )
102
+ if (live.length < modules.length) return live
103
+ }