@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
package/src/derive.mjs CHANGED
@@ -2,23 +2,85 @@
2
2
  // title gets its `h1`, one that sets no description gets its first real paragraph, cleaned and cut at a sentence. Pure
3
3
  // functions on a string, server-side only: `weft check` runs the same `descriptionProblem` on what a route wrote.
4
4
 
5
- const SKIP = new Set(['script', 'style', 'template', 'noscript', 'svg', 'math', 'nav', 'aside', 'footer', 'pre', 'figure', 'table', 'ul', 'ol', 'dl', 'details', 'button', 'select', 'textarea', 'form', 'dialog', 'iframe', 'canvas', 'object'])
6
- const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr'])
7
- const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', hellip: '…', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', middot: '·', copy: '©', reg: '®', trade: '™', times: '×', rarr: '→', larr: '←' }
5
+ const SKIP = new Set([
6
+ 'script',
7
+ 'style',
8
+ 'template',
9
+ 'noscript',
10
+ 'svg',
11
+ 'math',
12
+ 'nav',
13
+ 'aside',
14
+ 'footer',
15
+ 'pre',
16
+ 'figure',
17
+ 'table',
18
+ 'ul',
19
+ 'ol',
20
+ 'dl',
21
+ 'details',
22
+ 'button',
23
+ 'select',
24
+ 'textarea',
25
+ 'form',
26
+ 'dialog',
27
+ 'iframe',
28
+ 'canvas',
29
+ 'object',
30
+ ])
31
+ const VOID = new Set([
32
+ 'area',
33
+ 'base',
34
+ 'br',
35
+ 'col',
36
+ 'embed',
37
+ 'hr',
38
+ 'img',
39
+ 'input',
40
+ 'link',
41
+ 'meta',
42
+ 'source',
43
+ 'track',
44
+ 'wbr',
45
+ ])
46
+ const ENTITIES = {
47
+ amp: '&',
48
+ lt: '<',
49
+ gt: '>',
50
+ quot: '"',
51
+ apos: "'",
52
+ nbsp: ' ',
53
+ mdash: '—',
54
+ ndash: '–',
55
+ hellip: '…',
56
+ rsquo: '’',
57
+ lsquo: '‘',
58
+ ldquo: '“',
59
+ rdquo: '”',
60
+ middot: '·',
61
+ copy: '©',
62
+ reg: '®',
63
+ trade: '™',
64
+ times: '×',
65
+ rarr: '→',
66
+ larr: '←',
67
+ }
8
68
 
9
69
  /** `&amp;`, `&#39;`, `&#x27;` → their characters. An unknown name stays as written. */
10
- export const decode = text => text.replace(/&(#\d+|#x[\da-f]+|[a-z]+);/gi, (all, e) => {
11
- if (e[0] !== '#') return ENTITIES[e] ?? all
12
- const n = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1))
13
- return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : ''
14
- })
70
+ export const decode = (text) =>
71
+ text.replace(/&(#\d+|#x[\da-f]+|[a-z]+);/gi, (all, e) => {
72
+ if (e[0] !== '#') return ENTITIES[e] ?? all
73
+ const n = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1))
74
+ return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : ''
75
+ })
15
76
 
16
77
  /** One line of plain text: whitespace collapsed, no list marker in front, no space before punctuation. */
17
- export const cleanText = text => text
18
- .replace(/\s+/g, ' ')
19
- .replace(/\s+([.,;:!?)])(?=\s|$)/g, '$1')
20
- .trim()
21
- .replace(/^(?:[-*•‣–—]\s+|\d{1,3}[.)]\s+)+/, '')
78
+ export const cleanText = (text) =>
79
+ text
80
+ .replace(/\s+/g, ' ')
81
+ .replace(/\s+([.,;:!?)])(?=\s|$)/g, '$1')
82
+ .trim()
83
+ .replace(/^(?:[-*•‣–—]\s+|\d{1,3}[.)]\s+)+/, '')
22
84
 
23
85
  /** Not the end of a sentence, though it ends in a full stop. */
24
86
  const ABBREVIATION = /(?:^|[\s(])(?:e\.g|i\.e|etc|vs|cf|approx|no|fig|[A-Z])\.$/
@@ -43,17 +105,23 @@ export function truncate(text, max = 155) {
43
105
  /** `undefined`, or why this text isn't a description a person would read: markup, code, a list item or a fragment. */
44
106
  export function descriptionProblem(text) {
45
107
  const s = text.trim()
46
- if (/^(?:[-*•+]\s|\d{1,3}[.)]\s|#{1,6}\s|>\s)/.test(s)) return 'it starts with a markdown list marker or heading'
47
- if (/<\/?[a-z][^>]*>?|\/>|=>|\w\(\)|[{}]|`|\]\(|\bx = </i.test(s)) return 'it holds code or markup'
48
- if ((s.match(/[<>=;$[\]{}()|\\]/g)?.length ?? 0) / Math.max(s.length, 1) > 0.05) return 'it is mostly symbols, which reads as code'
49
- if (/\s[.,;:!?)](?=\s|$)/.test(s)) return 'it has a space before punctuation, the mark of inline code that was stripped out'
108
+ if (/^(?:[-*•+]\s|\d{1,3}[.)]\s|#{1,6}\s|>\s)/.test(s))
109
+ return 'it starts with a markdown list marker or heading'
110
+ if (/<\/?[a-z][^>]*>?|\/>|=>|\w\(\)|[{}]|`|\]\(|\bx = </i.test(s))
111
+ return 'it holds code or markup'
112
+ if ((s.match(/[<>=;$[\]{}()|\\]/g)?.length ?? 0) / Math.max(s.length, 1) > 0.05)
113
+ return 'it is mostly symbols, which reads as code'
114
+ if (/\s[.,;:!?)](?=\s|$)/.test(s))
115
+ return 'it has a space before punctuation, the mark of inline code that was stripped out'
50
116
  if (s.length && !/[.!?…"'”’)]$/.test(s) && s.length > 100) return 'it stops mid-sentence'
51
- if (/^[a-z]/.test(s) && !/^[a-z][\w.-]*[A-Z@/:]|^[a-z]+:/.test(s)) return 'it starts in lower case, like a fragment of a longer sentence'
117
+ if (/^[a-z]/.test(s) && !/^[a-z][\w.-]*[A-Z@/:]|^[a-z]+:/.test(s))
118
+ return 'it starts in lower case, like a fragment of a longer sentence'
52
119
  return undefined
53
120
  }
54
121
 
55
122
  /** A paragraph worth quoting: prose of some length, with no code in it. */
56
- const prose = text => text.length >= 40 && text.includes(' ') && !descriptionProblem(truncate(text))
123
+ const prose = (text) =>
124
+ text.length >= 40 && text.includes(' ') && !descriptionProblem(truncate(text))
57
125
 
58
126
  function tokens(html) {
59
127
  const out = []
@@ -67,7 +135,7 @@ function tokens(html) {
67
135
  return out
68
136
  }
69
137
 
70
- const hidden = attrs => /\s(?:hidden|aria-hidden=(?:"true"|'true'|true))(?:[\s/]|$)/.test(attrs)
138
+ const hidden = (attrs) => /\s(?:hidden|aria-hidden=(?:"true"|'true'|true))(?:[\s/]|$)/.test(attrs)
71
139
 
72
140
  /**
73
141
  * The page's own words: `{ title, description }`, either missing when the page doesn't have one. Reads `<main>` when
@@ -78,22 +146,40 @@ export function pageText(html) {
78
146
  const open = /<main\b/i.exec(html)
79
147
  const end = html.toLowerCase().lastIndexOf('</main>')
80
148
  const region = open ? html.slice(open.index, end > open.index ? end : undefined) : html
81
- let title, heading, description, skip, grab, buffer = ''
82
- const done = t => {
149
+ let title,
150
+ heading,
151
+ description,
152
+ skip,
153
+ grab,
154
+ buffer = ''
155
+ const done = (t) => {
83
156
  const text = cleanText(decode(buffer))
84
157
  grab = undefined
85
- if (t === 'p') { if (prose(text)) description ??= truncate(text) }
86
- else if (t !== 'h1') heading ??= text || undefined
87
- else if (text && !title) { title = text; description = undefined } // the description is what follows the title
158
+ if (t === 'p') {
159
+ if (prose(text)) description ??= truncate(text)
160
+ } else if (t !== 'h1') heading ??= text || undefined
161
+ else if (text && !title) {
162
+ title = text
163
+ description = undefined
164
+ } // the description is what follows the title
88
165
  }
89
166
  for (const t of tokens(region)) {
90
167
  if (skip) {
91
- if (t.tag === skip.tag && !VOID.has(t.tag) && !/\/\s*$/.test(t.attrs ?? '')) skip.depth += t.close ? -1 : 1
168
+ if (t.tag === skip.tag && !VOID.has(t.tag) && !/\/\s*$/.test(t.attrs ?? ''))
169
+ skip.depth += t.close ? -1 : 1
92
170
  if (!skip.depth) skip = undefined
93
171
  continue
94
172
  }
95
- if (t.text !== undefined) { if (grab) buffer += t.text; continue }
96
- if (!t.close && (SKIP.has(t.tag) || hidden(t.attrs)) && !VOID.has(t.tag) && !/\/\s*$/.test(t.attrs)) {
173
+ if (t.text !== undefined) {
174
+ if (grab) buffer += t.text
175
+ continue
176
+ }
177
+ if (
178
+ !t.close &&
179
+ (SKIP.has(t.tag) || hidden(t.attrs)) &&
180
+ !VOID.has(t.tag) &&
181
+ !/\/\s*$/.test(t.attrs)
182
+ ) {
97
183
  skip = { tag: t.tag, depth: 1 }
98
184
  continue
99
185
  }
@@ -102,7 +188,10 @@ export function pageText(html) {
102
188
  else if (t.tag === 'br') buffer += ' '
103
189
  continue
104
190
  }
105
- if (!t.close && /^(?:h1|h2|h3|p)$/.test(t.tag)) { grab = t.tag; buffer = '' }
191
+ if (!t.close && /^(?:h1|h2|h3|p)$/.test(t.tag)) {
192
+ grab = t.tag
193
+ buffer = ''
194
+ }
106
195
  if (title && description) break
107
196
  }
108
197
  return { title: title ?? heading, description }
@@ -114,25 +203,33 @@ export function pageText(html) {
114
203
  */
115
204
  export function deployUrl(site, env = {}) {
116
205
  if (site?.url) return site.url
117
- const https = host => host && `https://${host}`
118
- return env.WEFT_SITE_URL
119
- || https(env.VERCEL_PROJECT_PRODUCTION_URL) || https(env.VERCEL_URL)
120
- || env.URL // Netlify
121
- || env.CF_PAGES_URL || env.RENDER_EXTERNAL_URL
122
- || https(env.RAILWAY_PUBLIC_DOMAIN)
123
- || (env.FLY_APP_NAME && `https://${env.FLY_APP_NAME}.fly.dev`)
124
- || undefined
206
+ const https = (host) => host && `https://${host}`
207
+ return (
208
+ env.WEFT_SITE_URL ||
209
+ https(env.VERCEL_PROJECT_PRODUCTION_URL) ||
210
+ https(env.VERCEL_URL) ||
211
+ env.URL || // Netlify
212
+ env.CF_PAGES_URL ||
213
+ env.RENDER_EXTERNAL_URL ||
214
+ https(env.RAILWAY_PUBLIC_DOMAIN) ||
215
+ (env.FLY_APP_NAME && `https://${env.FLY_APP_NAME}.fly.dev`) ||
216
+ undefined
217
+ )
125
218
  }
126
219
 
127
220
  /** The built-in card's title size: bigger type for a shorter title. */
128
- export const cardTitleSize = title => (title.length <= 24 ? 92 : title.length <= 44 ? 76 : title.length <= 70 ? 62 : 52)
221
+ export const cardTitleSize = (title) =>
222
+ title.length <= 24 ? 92 : title.length <= 44 ? 76 : title.length <= 70 ? 62 : 52
129
223
 
130
224
  /** `paths` in pages that each fit in a response header, with room for the link to the next page. */
131
225
  export function pathPages(paths, max = 6000) {
132
226
  const pages = [[]]
133
227
  let size = 0
134
228
  for (const p of paths) {
135
- if (size + p.length + 1 > max) { pages.push([]); size = 0 }
229
+ if (size + p.length + 1 > max) {
230
+ pages.push([])
231
+ size = 0
232
+ }
136
233
  pages.at(-1).push(p)
137
234
  size += p.length + 1
138
235
  }
@@ -5,7 +5,7 @@
5
5
 
6
6
  const alternate = Boolean(process.versions.bun || process.versions.deno)
7
7
 
8
- export const devRunner = sockets => (alternate && sockets ? 'self' : undefined)
8
+ export const devRunner = (sockets) => (alternate && sockets ? 'self' : undefined)
9
9
 
10
10
  const GUARD = /if \("Deno" in globalThis \|\| "Bun" in globalThis\) throw new Error\([^)]*\);?/
11
11
 
@@ -23,19 +23,23 @@ export const devSockets = () => ({
23
23
  },
24
24
  transform: {
25
25
  filter: { id: /crossws/, code: 'incompatible environment' },
26
- handler: code => code.replace(GUARD, ''),
26
+ handler: (code) => code.replace(GUARD, ''),
27
27
  },
28
28
  })
29
29
 
30
30
  // Nitro can name an environment to the runner before Vite has made it. Across a process that takes long enough, but
31
31
  // `self` answers at once: the first request goes to nothing and the environment hangs for good. So each one is held
32
32
  // until it exists, and `init` names it again. (The worker keeps these on globalThis, and `self` is this process.)
33
- const HELD = { hasEvaluated: () => false, reload: async () => {}, fetch: () => Promise.resolve(new Response('starting', { status: 503 })) }
33
+ const HELD = {
34
+ hasEvaluated: () => false,
35
+ reload: async () => {},
36
+ fetch: () => Promise.resolve(new Response('starting', { status: 503 })),
37
+ }
34
38
 
35
39
  function hold(name) {
36
40
  const environments = (globalThis.__nitro_vite_envs__ ??= {})
37
41
  environments[name] = HELD
38
- return environment => {
42
+ return (environment) => {
39
43
  environments[name] = undefined
40
44
  return environment
41
45
  }
@@ -0,0 +1,57 @@
1
+ import { FnError, StartError, type Context, type Fn } from '../data'
2
+ import { unavailable } from '../db/core'
3
+
4
+ /** `auth: 'user'`: A003 when `ctx` has no `user` at all, A002 when no one is signed in. */
5
+ export function guard(fn: Fn, ctx: Context) {
6
+ if (fn.def.auth !== 'user') return
7
+ if (!('user' in ctx))
8
+ throw new StartError(
9
+ 'A003',
10
+ `\`${fn.$id}\` has \`auth: 'user'\`, but \`ctx\` has no \`user\``,
11
+ 'spread `session(auth, request)` into what `src/context.server.ts` returns',
12
+ { status: 500 },
13
+ )
14
+ if (!(ctx as { user?: unknown }).user)
15
+ throw new StartError(
16
+ 'A002',
17
+ `\`${fn.$id}\` needs a signed-in user`,
18
+ 'sign in first, or guard the route with `redirect()` in its `load`',
19
+ { status: 401 },
20
+ )
21
+ }
22
+
23
+ const DOWN =
24
+ /^(ECONNREFUSED|ECONNRESET|ENOTFOUND|EHOSTUNREACH|ETIMEDOUT|CONNECT_TIMEOUT|CONNECTION_(CLOSED|ENDED|DESTROYED)|PROTOCOL_CONNECTION_LOST|P100[12])$/
25
+
26
+ interface Failure {
27
+ cause?: Failure | null
28
+ answer?: { message?: string; code?: string; status: number }
29
+ code?: string | number
30
+ message?: string
31
+ }
32
+
33
+ /** A dropped connection is Q016; a missing table is a migration that wasn't generated or applied (Q014): the driver's error, with the fix. */
34
+ export function dbError(e: unknown): unknown {
35
+ const failure = e as Failure | null | undefined
36
+ const cause = failure?.cause ?? failure
37
+ if (failure?.answer) return new FnError(failure.answer, failure.answer.status)
38
+ if (cause instanceof StartError) return cause // Drizzle wraps what a driver throws, the gate's Q016 included
39
+ if (!(e instanceof StartError) && DOWN.test(String(cause?.code ?? failure?.code ?? '')))
40
+ return unavailable(e)
41
+ const m = cause?.message ?? ''
42
+ if (
43
+ e instanceof StartError ||
44
+ !(
45
+ /no such (table|column)|has no column named/.test(m) ||
46
+ cause?.code === '42P01' ||
47
+ cause?.code === '42703'
48
+ )
49
+ )
50
+ return e
51
+ return new StartError(
52
+ 'Q014',
53
+ `the database doesn't match the schema: ${m}`,
54
+ 'run `weft db generate`, then reload (new migrations apply on the next request)',
55
+ { status: 500 },
56
+ )
57
+ }
@@ -0,0 +1,66 @@
1
+ import { FnError, isRedirect, StartError } from '../data'
2
+ import type { Scope, ScopeEntry } from './scope'
3
+
4
+ /** How long a landed read waits for the others in flight, in ms: long enough for a pool's worth of replies. */
5
+ const GATHER = 4
6
+
7
+ interface Landing {
8
+ flying: number
9
+ held: (() => void)[]
10
+ timer?: ReturnType<typeof setTimeout> | undefined
11
+ }
12
+ const landings = new WeakMap<object, Landing>()
13
+
14
+ /**
15
+ * A read as a render sees it. Each landing costs a pass over the whole page, so reads land together: all of them once
16
+ * none is in flight, or `GATHER` after the first, so a slow query never holds a fast one for longer than that.
17
+ */
18
+ export function landed(scope: Scope, e: ScopeEntry): Promise<unknown> {
19
+ if (e.read) return e.read
20
+ let l = landings.get(scope.entries)
21
+ if (!l) landings.set(scope.entries, (l = { flying: 0, held: [] }))
22
+ const at = l
23
+ at.flying++
24
+ const release = () => {
25
+ clearTimeout(at.timer)
26
+ at.timer = undefined
27
+ for (const go of at.held.splice(0)) go()
28
+ }
29
+ e.read = new Promise((ok, no) => {
30
+ const land = () => {
31
+ at.held.push(() => (e.e !== undefined ? no(shown(e.e, scope)) : ok(e.v)))
32
+ if (!--at.flying) release()
33
+ else at.timer ??= setTimeout(release, GATHER)
34
+ }
35
+ e.p.then(land, land)
36
+ })
37
+ e.read.catch(() => {}) // a failure surfaces where the value is read
38
+ return e.read
39
+ }
40
+
41
+ const FAILED = 'Server function failed.'
42
+
43
+ /** A failure as the page may show it. In production a fault's text stays in the diagnostics: a driver's error names tables. */
44
+ export function shown(e: unknown, scope: Scope): unknown {
45
+ const { dev, onDiagnostic } = scope.options
46
+ const fault = !(
47
+ e instanceof FnError ||
48
+ isRedirect(e) ||
49
+ (e instanceof StartError && Number(e.extra.status ?? 500) < 500)
50
+ )
51
+ const { message, code } = (e ?? {}) as { message?: string; code?: string }
52
+ if (dev || !fault) {
53
+ if (message !== undefined) scope.said.add(message)
54
+ return e
55
+ }
56
+ onDiagnostic?.({
57
+ code: code ?? 'E_FN',
58
+ severity: 'error',
59
+ message: `a render read a failed query: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`,
60
+ })
61
+ return new Error(FAILED)
62
+ }
63
+
64
+ /** The message for a value that's going in a page: its own when `shown` passed it, else the plain one. */
65
+ export const said = (message: string, scope: Scope) =>
66
+ scope.options.dev || scope.said.has(message) ? message : FAILED
@@ -0,0 +1,174 @@
1
+ import type { MediaAsset } from '@weft-js/core/media'
2
+ import type { ServerExtension } from '../extension'
3
+ import { StartError, type Context } from '../data'
4
+ import { dbError } from './errors'
5
+
6
+ export interface ScopeEntry {
7
+ id: string
8
+ input: unknown
9
+ p: Promise<ScopeEntry>
10
+ /** A render's read of it (`landed`). */
11
+ read?: Promise<unknown>
12
+ done?: boolean
13
+ v?: unknown
14
+ e?: unknown
15
+ tags: string[]
16
+ }
17
+
18
+ export interface Scope {
19
+ request: Request
20
+ entries: Map<string, ScopeEntry>
21
+ /** Tags a mutation or `invalidate()` dropped during this request. */
22
+ invalidated: Set<string>
23
+ /** Response headers the functions set (cookies), merged into the response. */
24
+ headers: Headers
25
+ /** The page's first byte went out: a redirect can't change the status any more (Q007). */
26
+ started?: boolean
27
+ /** The tags of the queries a route level's `load` read (a frame per loader; research 27 §4). */
28
+ tags?: Set<string>
29
+ /** Dev: the tables this function's statements read and wrote (a frame per `run`; research 28 §2). */
30
+ touched?: Touched
31
+ /** A no-JS form POST that failed: `<Form>` renders it into the page (research 29 §5). */
32
+ form?: FormFailure
33
+ /** Failure messages meant for the visitor, the only ones a production page may carry (`shown`). */
34
+ said: Set<string>
35
+ /** The render read a live query or a stream: the page carries the realtime cursor (research 30 §1). */
36
+ live?: boolean
37
+ ctx(): Promise<Context>
38
+ options: ScopeOptions
39
+ }
40
+
41
+ export interface Touched {
42
+ read: Set<string>
43
+ write: Set<string>
44
+ }
45
+
46
+ export interface FormFailure {
47
+ id: string
48
+ key?: string
49
+ values: Record<string, unknown>
50
+ errors: Record<string, string>
51
+ message?: string | undefined
52
+ status: number
53
+ }
54
+
55
+ /** The `media.server@1` provider (`@weft-js/plugin-media/server`, research 29 §6). */
56
+ export interface MediaServer {
57
+ ingest(bytes: Uint8Array, options: { name?: string }): Promise<MediaAsset>
58
+ serve(want: { hash: string; width: number; format: string; accept: string }): Promise<{
59
+ bytes: Uint8Array
60
+ type: string
61
+ width: number
62
+ format: string
63
+ negotiated: boolean
64
+ temporary?: boolean
65
+ } | null>
66
+ }
67
+
68
+ export interface ScopeOptions {
69
+ /** The app's context (`src/context.server.ts`). */
70
+ context?: ((args: { request: Request }) => object | Promise<object>) | undefined
71
+ store?: Store | undefined
72
+ /** Tests (@weft-js/core/test `net.handle`): a replacement `run` by function id. */
73
+ handlers?: Map<string, (input: unknown, ctx: Context) => unknown>
74
+ onDiagnostic?: ((d: { code: string; severity: string; message: string }) => void) | undefined
75
+ dev?: boolean | undefined
76
+ media?: (() => Promise<MediaServer> | undefined) | undefined
77
+ /** What plugins add through `server.extension` (extension.ts). */
78
+ extensions?: ServerExtension[] | undefined
79
+ }
80
+
81
+ export function createScope(request: Request, options: ScopeOptions = {}): Scope {
82
+ const headers = new Headers()
83
+ let ctx: Promise<Context> | undefined
84
+ return {
85
+ request,
86
+ entries: new Map(),
87
+ invalidated: new Set(),
88
+ headers,
89
+ options,
90
+ said: new Set(),
91
+ // oxlint-disable typescript/no-unnecessary-type-assertion -- needed where a plugin such as pay adds required fields to Context
92
+ ctx: () =>
93
+ (ctx ??= Promise.resolve()
94
+ .then(() => options.context?.({ request }) ?? {})
95
+ .then(
96
+ (app) =>
97
+ extended(
98
+ {
99
+ ...app,
100
+ request,
101
+ response: { headers },
102
+ media: mediaApi(options),
103
+ } /* oxlint-enable typescript/no-unnecessary-type-assertion */ as Context,
104
+ options,
105
+ ),
106
+ (e) => {
107
+ throw dbError(e)
108
+ },
109
+ )),
110
+ }
111
+ }
112
+
113
+ /** Each extension's part of `ctx`, once the app's is there: an extension reads `ctx.user` to say what it adds. */
114
+ function extended(ctx: Context, options: ScopeOptions): Context {
115
+ for (const e of options.extensions ?? []) Object.assign(ctx, e.context?.(ctx))
116
+ return ctx
117
+ }
118
+
119
+ /** `ctx.media`. A refusal is the user's answer, with its own status; `<Form>` shows it. */
120
+ const mediaApi = (options: ScopeOptions): Context['media'] => ({
121
+ async ingest(file, { name } = {}) {
122
+ const server = await options.media?.()
123
+ if (!server)
124
+ throw new StartError(
125
+ 'M017',
126
+ '`ctx.media` needs a `media.server` provider',
127
+ 'add `media()` from `@weft-js/plugin-media` to `plugins` in weft.config.ts',
128
+ { status: 500 },
129
+ )
130
+ const bytes = file instanceof Uint8Array ? file : new Uint8Array(await file.arrayBuffer())
131
+ try {
132
+ return await server.ingest(bytes, { name: name ?? (file as File).name })
133
+ } catch (e) {
134
+ const { code, message } = e as Error & { code?: string }
135
+ throw code ? new StartError(code, message, undefined, { status: 422 }) : e
136
+ }
137
+ },
138
+ })
139
+
140
+ /** A child scope for one loader: the same entries, its own `tags` collector. */
141
+ export const frame = (scope: Scope): Scope =>
142
+ Object.create(scope, { tags: { value: new Set<string>() } })
143
+
144
+ // ---- the shared tagged cache --------------------------------------------------------------------------------------
145
+
146
+ /** Where `cache: { life }` values live. Memory by default; Nitro storage (fs, KV, Redis) plugs in here. */
147
+ export interface Store {
148
+ get(key: string): Stored | undefined | Promise<Stored | undefined>
149
+ set(key: string, value: Stored): void | Promise<void>
150
+ /** The current version of each tag (0 for one never bumped). */
151
+ versions(tags: string[]): number[] | Promise<number[]>
152
+ bump(tags: string[]): void | Promise<void>
153
+ }
154
+ export interface Stored {
155
+ v: unknown
156
+ tags: string[]
157
+ versions: number[]
158
+ expires: number
159
+ }
160
+
161
+ export function memoryStore(): Store {
162
+ const values = new Map<string, Stored>()
163
+ const tags = new Map<string, number>()
164
+ return {
165
+ get: (k) => values.get(k),
166
+ set: (k, v) => void values.set(k, v),
167
+ versions: (t) => t.map((x) => tags.get(x) ?? 0),
168
+ bump: (t) => {
169
+ for (const x of t) tags.set(x, (tags.get(x) ?? 0) + 1)
170
+ },
171
+ }
172
+ }
173
+
174
+ export const defaultStore = memoryStore()
@@ -0,0 +1,80 @@
1
+ import type { Fn } from '../data'
2
+ import type { Scope, ScopeOptions, Touched } from './scope'
3
+
4
+ export const tracker = (scope: Scope): Touched | undefined =>
5
+ scope.options.dev ? { read: new Set(), write: new Set() } : undefined
6
+
7
+ /** R105 (dev, once per function): a cached Rust query with tags that named no table, so Q011 has nothing to check it by. */
8
+ export function untouched(fn: Fn, read: Set<string>, tags: readonly string[], scope: Scope) {
9
+ if (!fn.def.impl || !fn.def.cache || read.size || !tags.length || warned.has(`${fn.$id}:R105`))
10
+ return
11
+ warned.add(`${fn.$id}:R105`)
12
+ scope.options.onDiagnostic?.({
13
+ code: 'R105',
14
+ severity: 'warning',
15
+ message: `\`${fn.$id}\` is cached under ${tags.map((t) => `\`${t}\``).join(', ')} and reported no table: nothing can tell whether those tags cover what it reads. Call \`ctx.touch("table")\` in the Rust, or drop \`tags\` if it reads none`,
16
+ })
17
+ }
18
+
19
+ /** Q011 (dev, once per function and table): a table the function touched that no tag names (PLAN §4). */
20
+ export function missedTags(
21
+ fn: Fn,
22
+ verb: 'read' | 'write',
23
+ tables: Set<string>,
24
+ tags: readonly string[],
25
+ scope: Scope,
26
+ ) {
27
+ for (const t of tables) {
28
+ const key = `${fn.$id}:${t}`
29
+ if (warned.has(key) || tags.some((x) => x === t || x.startsWith(`${t}:`))) continue
30
+ warned.add(key)
31
+ const list = fn.kind === 'query' ? '`tags`' : '`invalidates`'
32
+ scope.options.onDiagnostic?.({
33
+ code: 'Q011',
34
+ severity: 'warning',
35
+ message: `\`${fn.$id}\` ${verb === 'read' ? 'reads' : 'writes'} table \`${t}\`, but its ${list} don't name it: ${fn.kind === 'query' ? "a mutation of that table won't refresh it" : 'queries of that table stay stale'}. Add \`'${t}'\` to ${list}`,
36
+ })
37
+ }
38
+ }
39
+ const warned = new Set<string>()
40
+
41
+ /** Dev: the tables each query last read, and the tags of the queries read with `.live()` (the hub fills it). */
42
+ export const reads = new Map<string, Set<string>>()
43
+ export const liveQueries = new Map<string, string[]>()
44
+
45
+ /** R003 (dev, once per pair): this mutation wrote what a live query reads, and pokes none of its tags. */
46
+ export function missedPokes(fn: Fn, write: Set<string>, tags: string[], scope: Scope) {
47
+ for (const [id, own] of liveQueries) {
48
+ const table = [...(reads.get(id) ?? [])].find((t) => write.has(t))
49
+ const key = `${fn.$id}>${id}`
50
+ if (!table || warned.has(key) || own.some((t) => tags.includes(t))) continue
51
+ warned.add(key)
52
+ scope.options.onDiagnostic?.({
53
+ code: 'R003',
54
+ severity: 'warning',
55
+ message: `\`${fn.$id}\` writes table \`${table}\`, which live query \`${id}\` reads, but their tags don't intersect (${JSON.stringify(tags)} and ${JSON.stringify(own)}): open pages won't update. Add one of the query's tags to \`invalidates\``,
56
+ })
57
+ }
58
+ }
59
+
60
+ /** R012 (dev, once per query and table): a live query whose tags won't hear what `changes` reports, or a table it doesn't report. */
61
+ export function missedFeed(
62
+ fn: Fn,
63
+ tags: string[],
64
+ tables: string[],
65
+ report: ScopeOptions['onDiagnostic'],
66
+ ) {
67
+ for (const t of reads.get(fn.$id) ?? []) {
68
+ const key = `${fn.$id}~${t}`
69
+ const named = tags.some((x) => x === t || x.startsWith(`${t}:`))
70
+ if (warned.has(key) || (named && tables.includes(t))) continue
71
+ warned.add(key)
72
+ report?.({
73
+ code: 'R012',
74
+ severity: 'warning',
75
+ message: tables.includes(t)
76
+ ? `live query \`${fn.$id}\` reads table \`${t}\`, but no tag names it: writes from outside the app won't refresh it. Add \`'${t}'\` to its \`tags\``
77
+ : `live query \`${fn.$id}\` reads table \`${t}\`, which \`changes\` doesn't list: only mutations refresh it, not writes from outside the app. Add the table to \`dbChanges\``,
78
+ })
79
+ }
80
+ }