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