@weft-js/start 0.1.1 → 0.1.3

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 (214) hide show
  1. package/README.md +1278 -12
  2. package/crate/Cargo.lock +1347 -0
  3. package/crate/Cargo.toml +72 -0
  4. package/crate/README.md +322 -0
  5. package/crate/rust-toolchain.toml +3 -0
  6. package/crate/src/build/emit.rs +103 -0
  7. package/crate/src/build.rs +367 -0
  8. package/crate/src/ctx.rs +186 -0
  9. package/crate/src/envelope.rs +163 -0
  10. package/crate/src/functions.rs +173 -0
  11. package/crate/src/lib.rs +118 -0
  12. package/crate/src/native.rs +212 -0
  13. package/crate/src/otel.rs +62 -0
  14. package/crate/src/server.rs +203 -0
  15. package/package.json +46 -37
  16. package/src/app.mjs +38 -8
  17. package/src/asset-receipt.d.mts +13 -0
  18. package/src/asset-receipt.mjs +61 -0
  19. package/src/auth/better-auth.ts +215 -17
  20. package/src/auth/drizzle.ts +1 -1
  21. package/src/auth.ts +21 -6
  22. package/src/boot.ts +19 -8
  23. package/src/bus/postgres.ts +32 -19
  24. package/src/bus.ts +7 -4
  25. package/src/{capture.mjs → capture.ts} +34 -1
  26. package/src/changes-sql.d.mts +8 -2
  27. package/src/changes-sql.mjs +26 -10
  28. package/src/changes.ts +165 -66
  29. package/src/channel/connections.ts +216 -0
  30. package/src/channel/socket.ts +396 -0
  31. package/src/channel-fake.ts +19 -4
  32. package/src/channel.ts +120 -492
  33. package/src/client.tsx +9 -2
  34. package/src/contract/describe.mjs +231 -0
  35. package/src/contract.mjs +144 -157
  36. package/src/data/cache.ts +354 -0
  37. package/src/data/definitions.ts +94 -0
  38. package/src/data/draft.ts +104 -0
  39. package/src/data/foundation.ts +95 -0
  40. package/src/data/live-api.ts +22 -0
  41. package/src/data/mutation.ts +138 -0
  42. package/src/data/primitives.ts +135 -0
  43. package/src/data/query.ts +106 -0
  44. package/src/data/transport.ts +121 -0
  45. package/src/data/types.ts +303 -0
  46. package/src/data.ts +69 -894
  47. package/src/db/bun-sqlite.d.ts +4 -1
  48. package/src/db/changes-mysql.ts +61 -21
  49. package/src/db/changes-postgres.ts +197 -118
  50. package/src/db/changes-sqlite.ts +131 -64
  51. package/src/db/core.ts +38 -13
  52. package/src/db/index.ts +20 -4
  53. package/src/db/mysql.ts +22 -6
  54. package/src/db/postgres.ts +49 -13
  55. package/src/db/sqlite.ts +53 -27
  56. package/src/derive.d.mts +4 -1
  57. package/src/derive.mjs +136 -39
  58. package/src/dev-runtime.mjs +8 -4
  59. package/src/exec/errors.ts +57 -0
  60. package/src/exec/landing.ts +66 -0
  61. package/src/exec/scope.ts +174 -0
  62. package/src/exec/touch.ts +80 -0
  63. package/src/exec/validation.ts +176 -0
  64. package/src/exec.ts +191 -341
  65. package/src/extension.ts +8 -2
  66. package/src/first-paint.ts +26 -9
  67. package/src/form/value.ts +15 -0
  68. package/src/form.tsx +132 -43
  69. package/src/hub/channels.ts +102 -0
  70. package/src/hub/connections.ts +147 -0
  71. package/src/hub/delivery.ts +62 -0
  72. package/src/hub/feed.ts +168 -0
  73. package/src/hub/presence.ts +64 -0
  74. package/src/hub/route.ts +158 -0
  75. package/src/hub/subscriptions.ts +212 -0
  76. package/src/hub/topic.ts +98 -0
  77. package/src/hub/tracked.ts +12 -0
  78. package/src/hub/types.ts +190 -0
  79. package/src/hub/view.ts +147 -0
  80. package/src/hub.ts +132 -758
  81. package/src/index.ts +84 -10
  82. package/src/inline.mjs +356 -0
  83. package/src/inspect/compare.mjs +162 -0
  84. package/src/inspect/routes.mjs +154 -0
  85. package/src/inspect/samples.mjs +59 -0
  86. package/src/inspect/url.mjs +2 -0
  87. package/src/inspect.mjs +146 -187
  88. package/src/link/election.ts +178 -0
  89. package/src/link/lines.ts +27 -0
  90. package/src/link.ts +90 -166
  91. package/src/list-drizzle.ts +104 -18
  92. package/src/list.ts +127 -36
  93. package/src/listen.ts +32 -6
  94. package/src/live/api.ts +18 -0
  95. package/src/live/backoff.ts +6 -0
  96. package/src/live/frames.ts +42 -0
  97. package/src/live/read.ts +77 -0
  98. package/src/live/sub-types.ts +24 -0
  99. package/src/live/transport.ts +86 -0
  100. package/src/live/types.ts +52 -0
  101. package/src/live-fake.ts +20 -5
  102. package/src/live-target.mjs +112 -37
  103. package/src/live.ts +116 -225
  104. package/src/media.ts +8 -1
  105. package/src/migrations.mjs +14 -6
  106. package/src/mux.ts +161 -64
  107. package/src/og/DefaultCard.tsx +40 -9
  108. package/src/og/fonts.mjs +16 -8
  109. package/src/og/images.mjs +43 -22
  110. package/src/og/index.mjs +119 -43
  111. package/src/og/native.mjs +11 -4
  112. package/src/range.mjs +15 -3
  113. package/src/router.ts +56 -6
  114. package/src/routes.mjs +73 -22
  115. package/src/run/admission.ts +80 -0
  116. package/src/run/boot.tsx +115 -0
  117. package/src/run/cache-presentation.ts +67 -0
  118. package/src/run/cache.ts +254 -0
  119. package/src/run/helpers.ts +197 -0
  120. package/src/run/listeners.ts +155 -0
  121. package/src/run/navigation.ts +382 -0
  122. package/src/run/page-skeleton.tsx +35 -0
  123. package/src/run/prefetch.ts +128 -0
  124. package/src/run/retain-view.ts +18 -0
  125. package/src/run/shape-preparation.ts +97 -0
  126. package/src/run/shape-styles.ts +72 -0
  127. package/src/run/source-context/pin.ts +124 -0
  128. package/src/run/source-context.ts +311 -0
  129. package/src/run.tsx +50 -178
  130. package/src/rust/common.ts +163 -0
  131. package/src/rust/native.ts +282 -0
  132. package/src/rust.ts +103 -229
  133. package/src/server/card.tsx +65 -0
  134. package/src/server/compression.ts +115 -0
  135. package/src/server/core.ts +70 -0
  136. package/src/server/dispatch.ts +70 -0
  137. package/src/server/form.ts +85 -0
  138. package/src/server/functions.ts +129 -0
  139. package/src/server/handler.ts +39 -0
  140. package/src/server/head.ts +96 -0
  141. package/src/server/media.ts +60 -0
  142. package/src/server/navigation-cache.ts +80 -0
  143. package/src/server/navigation.tsx +85 -0
  144. package/src/server/page-cold.ts +95 -0
  145. package/src/server/page-stream.tsx +181 -0
  146. package/src/server/page-types.ts +51 -0
  147. package/src/server/page-warm.ts +130 -0
  148. package/src/server/page.ts +69 -0
  149. package/src/server/payload.ts +89 -0
  150. package/src/server/prerender.ts +60 -0
  151. package/src/server/preview.tsx +67 -0
  152. package/src/server/render.ts +167 -0
  153. package/src/server/request.ts +98 -0
  154. package/src/server/resolver.ts +228 -0
  155. package/src/server/routes.ts +150 -0
  156. package/src/server/services.ts +69 -0
  157. package/src/server/text.ts +29 -0
  158. package/src/server/types.ts +160 -0
  159. package/src/server.tsx +82 -971
  160. package/src/shape.mjs +30 -6
  161. package/src/share/display.ts +50 -0
  162. package/src/share/network.ts +119 -0
  163. package/src/share/receive.ts +70 -0
  164. package/src/share/state.ts +30 -0
  165. package/src/share/tab-queue.ts +28 -0
  166. package/src/share-server.ts +128 -43
  167. package/src/share.ts +103 -209
  168. package/src/sheets.mjs +30 -18
  169. package/src/sidecar/build.mjs +170 -0
  170. package/src/sidecar/cargo.mjs +164 -0
  171. package/src/sidecar/common.mjs +17 -0
  172. package/src/sidecar/dev-build.mjs +91 -0
  173. package/src/sidecar/dev-watch.mjs +88 -0
  174. package/src/sidecar/manifest.mjs +19 -0
  175. package/src/sidecar/process.mjs +58 -0
  176. package/src/sidecar/refresh.mjs +19 -0
  177. package/src/sidecar.mjs +75 -416
  178. package/src/socket/check.ts +50 -0
  179. package/src/socket/deliver.ts +16 -0
  180. package/src/socket/publish.ts +56 -0
  181. package/src/socket/rooms.ts +41 -0
  182. package/src/socket/route.ts +79 -0
  183. package/src/socket/shared.ts +26 -0
  184. package/src/socket/types.ts +48 -0
  185. package/src/socket.ts +205 -194
  186. package/src/stream.ts +10 -1
  187. package/src/verify/app.ts +39 -0
  188. package/src/verify/cleanup.ts +27 -0
  189. package/src/verify/database.ts +11 -0
  190. package/src/verify/live.ts +63 -0
  191. package/src/verify/notify.ts +35 -0
  192. package/src/verify/skip.ts +19 -0
  193. package/src/verify/support.ts +149 -0
  194. package/src/verify/timing.ts +15 -0
  195. package/src/verify/trigger.ts +14 -0
  196. package/src/verify.ts +77 -176
  197. package/src/view.tsx +96 -11
  198. package/src/vite/asset-bytes.mjs +95 -0
  199. package/src/vite/assets.mjs +117 -0
  200. package/src/vite/config-hooks.mjs +166 -0
  201. package/src/vite/dev-hooks.mjs +103 -0
  202. package/src/vite/dev.mjs +102 -0
  203. package/src/vite/entries.mjs +145 -0
  204. package/src/vite/module-hooks.mjs +288 -0
  205. package/src/vite/route-modules.mjs +289 -0
  206. package/src/vite/server-functions.mjs +222 -0
  207. package/src/vite/shape-entry-css.mjs +187 -0
  208. package/src/vite/shape-override.mjs +226 -0
  209. package/src/vite/shared.mjs +51 -0
  210. package/src/vite/sheets.mjs +92 -0
  211. package/src/vite/state.mjs +89 -0
  212. package/src/vite/syntax.mjs +139 -0
  213. package/src/vite.mjs +152 -853
  214. package/src/capture.d.mts +0 -1
@@ -1,12 +1,99 @@
1
1
  // Better Auth as weft's default auth provider (research 28 §3, 34 §5). `src/auth.server.ts` default-exports `createAuth(…)`.
2
2
  // No client code: sign-in is a mutation, so nothing of Better Auth ships to the browser.
3
3
 
4
- import { betterAuth, type BetterAuthOptions } from 'better-auth'
5
- import { CLIENT_IP, StartError } from '../data'
4
+ import { betterAuth, type BetterAuthOptions, type BetterAuthPlugin } from 'better-auth'
5
+ import { createAuthMiddleware } from 'better-auth/api'
6
+ import { expireCookie } from 'better-auth/cookies'
7
+ import { jwt, type JwtOptions } from 'better-auth/plugins/jwt'
8
+ import { CLIENT_IP, currentScope, fnOf, StartError } from '../data'
6
9
  import type { DbHandle } from '../db/core'
7
10
 
8
11
  const SESSION = /(?:^|;\s*)(?:__Secure-)?[\w.-]*session_token=/
9
- const buildSecret = () => Array.from(crypto.getRandomValues(new Uint8Array(32)), x => x.toString(16).padStart(2, '0')).join('')
12
+ const BEARER = /^Bearer\s+(\S+)$/i
13
+ const CHUNK = /^(?:__Secure-)?[\w.-]*session_data\.\d+=/
14
+ /** A socket or stream keeps its `ctx` past this request, and an extension's route is its own, so they never read the cache. */
15
+ const HELD = /^\/_weft\/(rt|ws|x\/)/
16
+ const FN = '/_weft/fn/'
17
+ const ANONYMOUS = { user: null, session: null }
18
+ const TABLES = ['user', 'session', 'account', 'verification', 'jwks']
19
+
20
+ /** A cache too big for one cookie is no cache: that session just reads the database. An expired chunk still goes out. */
21
+ const oversized = (line: string) =>
22
+ CHUNK.test(line) && !/max-age=0/i.test(line) && !/^[^=]+=;/.test(line)
23
+
24
+ /** Better Auth's own routes follow the rule `session()` does: only reading who you are can answer from the cache. */
25
+ const cacheRules: BetterAuthPlugin = {
26
+ id: 'weft-session-cache',
27
+ hooks: {
28
+ // `/token` above all: a revoked session mustn't mint a 15-minute token from a cookie it still holds.
29
+ before: [
30
+ {
31
+ matcher: (c) => c.path !== '/get-session',
32
+ handler: createAuthMiddleware((c) =>
33
+ Promise.resolve({ context: { query: { ...c.query, disableCookieCache: true } } }),
34
+ ),
35
+ },
36
+ ],
37
+ // With `revokeOtherSessions` this response already carries the new session's cookies.
38
+ after: [
39
+ {
40
+ matcher: (c) => c.path === '/change-password',
41
+ handler: createAuthMiddleware((c) =>
42
+ Promise.resolve(
43
+ void (c.context.newSession || expireCookie(c, c.context.authCookies.sessionData)),
44
+ ),
45
+ ),
46
+ },
47
+ // Chunks go on every request after this one: a big enough user would pass the server's header limit and be locked out with 431s.
48
+ {
49
+ matcher: () => true,
50
+ handler: createAuthMiddleware((c) => {
51
+ const out = (c.context as { responseHeaders?: Headers }).responseHeaders
52
+ const all = out?.getSetCookie() ?? []
53
+ if (out && all.some(oversized)) {
54
+ out.delete('set-cookie')
55
+ for (const line of all) if (!oversized(line)) out.append('set-cookie', line)
56
+ }
57
+ return Promise.resolve()
58
+ }),
59
+ },
60
+ ],
61
+ },
62
+ }
63
+
64
+ /** `createAuth({ api })`: Better Auth's `jwt` options, and which server functions take a bearer token. */
65
+ export interface ApiAccess extends JwtOptions {
66
+ /** The server functions a bearer token may call, as a function so this module needn't load them first. No others read it. */
67
+ functions?: () => unknown[]
68
+ }
69
+ type Found = NonNullable<
70
+ Awaited<ReturnType<ReturnType<typeof betterAuth<BetterAuthOptions>>['api']['getSession']>>
71
+ >
72
+ type Session = { user: Found['user'] | null; session: Found['session'] | null }
73
+ /** What a token of ours carries: Better Auth's claims, the session it came from, and the user's fields. */
74
+ type Claims = {
75
+ sub: string
76
+ sid: string
77
+ iss?: string
78
+ aud?: string | string[]
79
+ exp?: number
80
+ iat?: number
81
+ } & Record<string, unknown>
82
+ /** The calls made here. The `jwt` plugin is optional, so `verifyJWT` isn't in the instance's own type. */
83
+ interface Methods {
84
+ verifyJWT(input: { body: { token: string } }): Promise<{ payload: Claims | null }>
85
+ getSession(input: {
86
+ headers: Headers
87
+ returnHeaders: true
88
+ query?: { disableCookieCache: boolean }
89
+ }): Promise<{ headers: Headers; response: Found | null }>
90
+ }
91
+ type Signed = Parameters<NonNullable<NonNullable<JwtOptions['jwt']>['definePayload']>>[0]
92
+
93
+ const buildSecret = () =>
94
+ Array.from(crypto.getRandomValues(new Uint8Array(32)), (x) =>
95
+ x.toString(16).padStart(2, '0'),
96
+ ).join('')
10
97
 
11
98
  /** `createAuth`: the database, the secret, and any Better Auth option on top. */
12
99
  export interface AuthOptions<S extends Record<string, unknown>, O extends BetterAuthOptions> {
@@ -14,16 +101,102 @@ export interface AuthOptions<S extends Record<string, unknown>, O extends Better
14
101
  /** The tables, Better Auth's included, for a data layer that needs them. */
15
102
  schema?: S
16
103
  /** 32+ characters (A001 in production). */
17
- secret?: string
104
+ secret?: string | undefined
18
105
  /** The rest of Better Auth's options; they win over the defaults here. */
19
106
  options?: O
107
+ /** Short-lived JWTs for other services and API clients, with a public JWKS: Better Auth's `jwt` options. `false` turns it off. */
108
+ api?: ApiAccess | false
20
109
  }
21
110
 
22
- /** A Better Auth instance with Weft's defaults, on the store `options.database` names. */
23
- export function createAuth<S extends Record<string, unknown>, const O extends BetterAuthOptions = {}>({ db, secret, options }: AuthOptions<S, O>) {
111
+ function sessionReader(
112
+ auth: {
113
+ $context: Promise<{
114
+ internalAdapter: {
115
+ listSessions(
116
+ user: string,
117
+ options: { onlyActiveSessions: boolean },
118
+ ): Promise<{ id: string }[]>
119
+ }
120
+ }>
121
+ },
122
+ methods: Methods,
123
+ claims: { issuer?: string | undefined; audience?: string | string[] | undefined },
124
+ functions: (() => unknown[]) | undefined,
125
+ api: ApiAccess | false | undefined,
126
+ ) {
127
+ /** Who a token says, for a function that takes one. A write also needs the session behind it to still be there. */
128
+ async function bearer(token: string, read: boolean): Promise<Session> {
129
+ // Unchecked, Better Auth would skip `iss` and `aud`: with no URL configured, no token is good.
130
+ if (!claims.issuer || !claims.audience) return ANONYMOUS
131
+ const { payload } = await methods.verifyJWT({ body: { token } })
132
+ if (!payload) return ANONYMOUS
133
+ if (!read) {
134
+ const live = await (
135
+ await auth.$context
136
+ ).internalAdapter.listSessions(payload.sub, { onlyActiveSessions: true })
137
+ if (!live.some((s) => s.id === payload.sid)) return ANONYMOUS
138
+ }
139
+ const { iss: _iss, aud: _aud, exp: _exp, iat: _iat, sub, sid: _sid, ...user } = payload
140
+ return { user: { ...user, id: sub } as unknown as Found['user'], session: null }
141
+ }
142
+
143
+ const marked = (pathname: string) => {
144
+ if (!functions || !pathname.startsWith(FN)) return false
145
+ const id = pathname.slice(FN.length)
146
+ return functions().some((f) => encodeURIComponent((fnOf(f) as { $id: string }).$id) === id)
147
+ }
148
+
149
+ return async function session(request: Request): Promise<Session> {
150
+ const read = request.method === 'GET' || request.method === 'HEAD'
151
+ const { pathname } = new URL(request.url)
152
+ const token = request.headers.get('authorization')?.match(BEARER)?.[1]
153
+ if (token && api !== false && marked(pathname)) return bearer(token, read)
154
+ if (!SESSION.test(request.headers.get('cookie') ?? '')) return ANONYMOUS
155
+ const { headers, response: s } = await methods.getSession({
156
+ headers: request.headers,
157
+ returnHeaders: true,
158
+ ...(!(read && !HELD.test(pathname)) && { query: { disableCookieCache: true } }),
159
+ })
160
+ // A fresh cache cookie, a slid expiry or a dead session's removal. Not `forward`: who's signed in hasn't changed.
161
+ // A socket's re-check runs inside whichever request poked it, and its cookies are that socket's, not this response's.
162
+ const scope = currentScope() as { request?: Request; headers?: Headers } | undefined
163
+ if (scope?.headers && scope.request === request)
164
+ for (const c of headers.getSetCookie()) scope.headers.append('set-cookie', c)
165
+ return { user: s?.user ?? null, session: s?.session ?? null }
166
+ }
167
+ }
168
+
169
+ function requireStore(db: { $weft: DbHandle }, options: BetterAuthOptions | undefined) {
24
170
  const handle = db.$weft
25
- if (options?.database) for (const name of ['user', 'session', 'account', 'verification']) handle.exempt.add(name)
26
- else throw new StartError('A005', 'Better Auth has no store', 'pass `options.database`: `drizzleAuth(db, schema)` from @weft-js/start/auth/drizzle, or your data layer\'s adapter')
171
+ if (options?.database) for (const name of TABLES) handle.exempt.add(name)
172
+ else
173
+ throw new StartError(
174
+ 'A005',
175
+ 'Better Auth has no store',
176
+ "pass `options.database`: `drizzleAuth(db, schema)` from @weft-js/start/auth/drizzle, or your data layer's adapter",
177
+ )
178
+ }
179
+
180
+ /** A Better Auth instance with Weft's defaults, on the store `options.database` names. */
181
+ export function createAuth<
182
+ S extends Record<string, unknown>,
183
+ const O extends BetterAuthOptions = {},
184
+ >({ db, secret, options, api }: AuthOptions<S, O>) {
185
+ requireStore(db, options)
186
+ const { functions, ...access } = api || {}
187
+ const origin =
188
+ typeof options?.baseURL === 'string' ? options.baseURL : process.env.BETTER_AUTH_URL
189
+ const claims = {
190
+ expirationTime: '15m',
191
+ issuer: origin,
192
+ audience: origin,
193
+ ...access.jwt,
194
+ // Which session a token came from, so a write can ask whether it still exists.
195
+ definePayload: async (s: Signed) => ({
196
+ ...((await access.jwt?.definePayload?.(s)) ?? s.user),
197
+ sid: s.session.id,
198
+ }),
199
+ }
27
200
  const auth = betterAuth({
28
201
  // A build loads this module with no environment, and in production Better Auth refuses to start without a secret: the
29
202
  // build gets one of its own, since nothing it makes is signed. A server still needs the real one (A001).
@@ -33,26 +206,51 @@ export function createAuth<S extends Record<string, unknown>, const O extends Be
33
206
  telemetry: { enabled: false },
34
207
  emailAndPassword: { enabled: true },
35
208
  ...options,
209
+ // Login stays an opaque token in the database. This only spares reads of it, for a window: see `session()`.
210
+ session: {
211
+ ...options?.session,
212
+ cookieCache: {
213
+ enabled: true,
214
+ maxAge: 300,
215
+ strategy: 'compact',
216
+ ...options?.session?.cookieCache,
217
+ },
218
+ },
219
+ // Signing a JWT on every session read would put a query back on each page, and login has no use for one.
220
+ plugins: [
221
+ ...(options?.plugins ?? []),
222
+ cacheRules,
223
+ ...(api === false ? [] : [jwt({ ...access, jwt: claims, disableSettingJwtHeader: true })]),
224
+ ],
36
225
  // The server stamps this header from the socket (server.tsx), so rate limits aren't one bucket for everyone.
37
226
  // Its schema check runs when this module loads. On Kysely that connects, in a build, and before a fresh database has
38
227
  // had weft's migrations. Q014 covers a schema that doesn't match, so it's off unless you turn it on.
39
- advanced: { ...options?.advanced, database: { validateSchema: false, ...options?.advanced?.database }, ipAddress: { ipAddressHeaders: [CLIENT_IP], ...options?.advanced?.ipAddress } },
228
+ advanced: {
229
+ ...options?.advanced,
230
+ database: { validateSchema: false, ...options?.advanced?.database },
231
+ ipAddress: { ipAddressHeaders: [CLIENT_IP], ...options?.advanced?.ipAddress },
232
+ },
40
233
  })
234
+ const methods = auth.api as unknown as Methods
235
+
41
236
  return Object.assign(auth, {
42
237
  basePath: options?.basePath,
43
- /** With no session cookie it's `null` without a query, so anonymous pages don't touch the database. */
44
- async session(request: Request) {
45
- if (!SESSION.test(request.headers.get('cookie') ?? '')) return { user: null, session: null }
46
- const s = await (auth as unknown as ReturnType<typeof betterAuth<BetterAuthOptions>>).api.getSession({ headers: request.headers })
47
- return { user: s?.user ?? null, session: s?.session ?? null }
48
- },
238
+ /**
239
+ * With no session cookie it's `null` without a query, so anonymous pages don't touch the database. A page or query
240
+ * reads the signed cookie cache; a mutation (not GET) and anything that outlives the request read the database, so a
241
+ * revoked session may be shown a page for the cache's window but can't write.
242
+ */
243
+ session: sessionReader(auth, methods, claims, functions, api),
49
244
  $weft: {
50
245
  /** On the first request (never in a build, which holds no secrets). */
51
246
  check() {
52
247
  if (process.env.NODE_ENV === 'production' && (secret?.length ?? 0) < 32)
53
- throw new StartError('A001', 'the auth secret is missing or under 32 characters', 'set `BETTER_AUTH_SECRET` (`openssl rand -base64 32`) in the server\'s environment')
248
+ throw new StartError(
249
+ 'A001',
250
+ 'the auth secret is missing or under 32 characters',
251
+ "set `BETTER_AUTH_SECRET` (`openssl rand -base64 32`) in the server's environment",
252
+ )
54
253
  },
55
254
  },
56
255
  })
57
256
  }
58
-
@@ -9,7 +9,7 @@ const PROVIDER = { sqlite: 'sqlite', postgresql: 'pg', mysql: 'mysql' } as const
9
9
 
10
10
  /** Better Auth's store on a Weft database, for `createAuth`'s `options.database`. */
11
11
  export function drizzleAuth(db: { $weft: DbHandle }, schema?: Record<string, unknown>) {
12
- for (const name of ['user', 'session', 'account', 'verification']) {
12
+ for (const name of ['user', 'session', 'account', 'verification', 'jwks']) {
13
13
  const t = schema?.[name]
14
14
  if (isTable(t)) db.$weft.exempt.add(getTableName(t))
15
15
  }
package/src/auth.ts CHANGED
@@ -8,10 +8,13 @@ import { FnError, type Context } from './data'
8
8
  export interface AuthProvider<U = unknown, S = unknown> {
9
9
  /** Serves everything under `basePath`. */
10
10
  handler(request: Request): Promise<Response> | Response
11
- /** Who this request is. Anonymous is `{ user: null, session: null }`, and should cost no query. */
11
+ /**
12
+ * Who this request is. Anonymous is `{ user: null, session: null }`, and should cost no query. A provider that caches
13
+ * sessions may answer a GET from the cache, and must ask its store for any other method: that's what a mutation runs on.
14
+ */
12
15
  session(request: Request): Promise<{ user: U | null; session: S | null }>
13
16
  /** Where `handler` is mounted (default `/api/auth`). */
14
- basePath?: string
17
+ basePath?: string | undefined
15
18
  $weft?: {
16
19
  /** On the first request, never in a build: throw for a server that can't run safely. */
17
20
  check(): void
@@ -24,7 +27,10 @@ export function defineAuth<U, S>(provider: AuthProvider<U, S>): AuthProvider<U,
24
27
  }
25
28
 
26
29
  /** `{ user, session }` for `ctx` (`src/context.server.ts`), from any provider. */
27
- export const session = <A extends { session(request: Request): unknown }>(auth: A, request: Request): ReturnType<A['session']> => auth.session(request) as ReturnType<A['session']>
30
+ export const session = <A extends { session(request: Request): unknown }>(
31
+ auth: A,
32
+ request: Request,
33
+ ): ReturnType<A['session']> => auth.session(request) as ReturnType<A['session']>
28
34
 
29
35
  const changed = new WeakSet<Headers>()
30
36
 
@@ -35,13 +41,22 @@ export const sessionChanged = (headers: Headers) => changed.has(headers)
35
41
  * Finish a provider's Response (Better Auth's with `asResponse: true`): its cookies go on this response, so a redirect
36
42
  * after it is a full load, and a failure rejects with the provider's message (`e.message` on the client).
37
43
  */
38
- export async function forward<T = unknown>(ctx: Context, call: Response | Promise<Response>): Promise<T> {
44
+ export async function forward<T = unknown>(
45
+ ctx: Context,
46
+ call: Response | Promise<Response>,
47
+ ): Promise<T> {
39
48
  const res = await call
40
49
  for (const c of res.headers.getSetCookie()) {
41
50
  ctx.response.headers.append('set-cookie', c)
42
51
  changed.add(ctx.response.headers)
43
52
  }
44
- const body = await res.json().catch(() => null) as (T & { code?: string; message?: string }) | null
45
- if (!res.ok) throw new FnError({ ...(body?.code && { code: body.code }), message: body?.message ?? 'Authentication failed' }, res.status)
53
+ const body = (await res.json().catch(() => null)) as
54
+ | (T & { code?: string; message?: string })
55
+ | null
56
+ if (!res.ok)
57
+ throw new FnError(
58
+ { ...(body?.code && { code: body.code }), message: body?.message ?? 'Authentication failed' },
59
+ res.status,
60
+ )
46
61
  return body as T
47
62
  }
package/src/boot.ts CHANGED
@@ -3,7 +3,15 @@
3
3
  // less data gets it on interaction only. `#weft-route` and the islands' comments are read by `start()` once it's here.
4
4
 
5
5
  /** What the inline script left on `window`: the queue, and how to play or wake it. */
6
- export interface Held { q: unknown[]; p: (held: unknown) => void; w?: (k: Element, lead?: 1) => void; r?: 1; n?: unknown; l?: Element; d?: unknown }
6
+ export interface Held {
7
+ q: unknown[]
8
+ p: (held: unknown) => void
9
+ w?: (k: Element, lead?: 1) => void
10
+ r?: 1
11
+ n?: unknown
12
+ l?: Element
13
+ d?: unknown
14
+ }
7
15
 
8
16
  export function boot(full: () => Promise<unknown>) {
9
17
  const q = (window as Window & { __wq?: Held }).__wq
@@ -26,13 +34,16 @@ export function boot(full: () => Promise<unknown>) {
26
34
  if (q.d) return
27
35
  const first = new WeakSet<Element>()
28
36
  const needs = new WeakSet<Element>()
29
- io = new IntersectionObserver(list => {
30
- for (const e of list) {
31
- const was = first.has(e.target)
32
- first.add(e.target)
33
- if (e.isIntersecting && (was || needs.has(e.target))) return go()
34
- }
35
- }, { rootMargin: '600px' })
37
+ io = new IntersectionObserver(
38
+ (list) => {
39
+ for (const e of list) {
40
+ const was = first.has(e.target)
41
+ first.add(e.target)
42
+ if (e.isIntersecting && (was || needs.has(e.target))) return go()
43
+ }
44
+ },
45
+ { rootMargin: '600px' },
46
+ )
36
47
  const w = document.createTreeWalker(document.body, 128)
37
48
  while (w.nextNode()) {
38
49
  const c = w.currentNode as Comment
@@ -16,11 +16,23 @@ const CHANNEL = 'weft_bus'
16
16
  const WHOLE = 7_900
17
17
  const CHUNK = 7_800
18
18
 
19
+ function delivery(listeners: Map<string, Set<(message: string) => void>>) {
20
+ return (text: string) => {
21
+ const at = text.indexOf('\n')
22
+ const list = listeners.get(text.slice(0, at))
23
+ if (list) for (const on of list) on(text.slice(at + 1))
24
+ }
25
+ }
26
+
19
27
  /** A bus on Postgres NOTIFY, for several instances with no broker beside the database. */
20
28
  export function postgresBus({ url }: PostgresBusOptions): Bus {
21
29
  if (!url && process.env.WEFT_BUILD) url = 'postgres://localhost/build' // a build loads this module and never connects
22
30
  if (!/^postgres(ql)?:\/\//.test(url ?? ''))
23
- throw new StartError('Q013', `\`postgresBus\` needs a \`postgres://\` URL, not \`${(url ?? '').replace(/\/\/.*@/, '//…@')}\``, 'set `DATABASE_URL`, or leave `bus` out for the memory bus on one instance')
31
+ throw new StartError(
32
+ 'Q013',
33
+ `\`postgresBus\` needs a \`postgres://\` URL, not \`${(url ?? '').replace(/\/\/.*@/, '//…@')}\``,
34
+ 'set `DATABASE_URL`, or leave `bus` out for the memory bus on one instance',
35
+ )
24
36
  let sql: ReturnType<typeof postgres> | undefined
25
37
  const client = () => (sql ??= postgres(url, { max: 2, idle_timeout: 20, onnotice: () => {} }))
26
38
  const listeners = new Map<string, Set<(message: string) => void>>()
@@ -29,15 +41,12 @@ export function postgresBus({ url }: PostgresBusOptions): Bus {
29
41
  let listening: Promise<unknown> | undefined
30
42
  let connected = false
31
43
 
32
- const deliver = (text: string) => {
33
- const at = text.indexOf('\n')
34
- const list = listeners.get(text.slice(0, at))
35
- if (list) for (const on of list) on(text.slice(at + 1))
36
- }
44
+ const deliver = delivery(listeners)
37
45
  const received = (payload: string) => {
38
46
  if (payload[0] === '=') return deliver(payload.slice(1))
39
47
  const [id, i, n, body] = payload.slice(1).split(':')
40
- if (id === undefined || i === undefined || n === undefined || body === undefined) return undefined
48
+ if (id === undefined || i === undefined || n === undefined || body === undefined)
49
+ return undefined
41
50
  const got = parts.get(id) ?? []
42
51
  got[+i] = body
43
52
  parts.set(id, got)
@@ -46,16 +55,19 @@ export function postgresBus({ url }: PostgresBusOptions): Bus {
46
55
  deliver(Buffer.from(got.join(''), 'base64').toString())
47
56
  }
48
57
  /** `onlisten` runs on every (re)connect: after the first, whatever was published meanwhile is gone. */
49
- const listen = () => (listening ??= client().listen(CHANNEL, received, () => {
50
- if (connected) {
51
- parts.clear()
52
- for (const gap of gaps) gap()
53
- }
54
- connected = true
55
- }).catch(e => {
56
- listening = undefined // the next subscribe tries again
57
- throw e
58
- }))
58
+ const listen = () =>
59
+ (listening ??= client()
60
+ .listen(CHANNEL, received, () => {
61
+ if (connected) {
62
+ parts.clear()
63
+ for (const gap of gaps) gap()
64
+ }
65
+ connected = true
66
+ })
67
+ .catch((e) => {
68
+ listening = undefined // the next subscribe tries again
69
+ throw e
70
+ }))
59
71
 
60
72
  return {
61
73
  name: 'postgres',
@@ -65,8 +77,9 @@ export function postgresBus({ url }: PostgresBusOptions): Bus {
65
77
  const b64 = Buffer.from(text).toString('base64')
66
78
  const id = crypto.randomUUID()
67
79
  const n = Math.ceil(b64.length / CHUNK)
68
- await client().begin(async tx => {
69
- for (let i = 0; i < n; i++) await tx`select pg_notify(${CHANNEL}, ${`+${id}:${i}:${n}:${b64.slice(i * CHUNK, (i + 1) * CHUNK)}`})`
80
+ await client().begin(async (tx) => {
81
+ for (let i = 0; i < n; i++)
82
+ await tx`select pg_notify(${CHANNEL}, ${`+${id}:${i}:${n}:${b64.slice(i * CHUNK, (i + 1) * CHUNK)}`})`
70
83
  })
71
84
  },
72
85
  async subscribe(channel, on, gap) {
package/src/bus.ts CHANGED
@@ -19,9 +19,9 @@ export function slotBus(plugin: string, load: () => Promise<Bus>): Bus {
19
19
  const get = () => (bus ??= load())
20
20
  return {
21
21
  name: plugin.replace(/^.*realtime-/, ''),
22
- publish: (channel, message) => get().then(b => b.publish(channel, message)),
23
- subscribe: (channel, on, gap) => get().then(b => b.subscribe(channel, on, gap)),
24
- close: () => (bus ? bus.then(b => b.close?.()) : Promise.resolve()),
22
+ publish: (channel, message) => get().then((b) => b.publish(channel, message)),
23
+ subscribe: (channel, on, gap) => get().then((b) => b.subscribe(channel, on, gap)),
24
+ close: () => (bus ? bus.then((b) => b.close?.()) : Promise.resolve()),
25
25
  }
26
26
  }
27
27
 
@@ -32,7 +32,10 @@ export function memoryBus(): Bus {
32
32
  name: 'memory',
33
33
  publish(channel, message) {
34
34
  const list = channels.get(channel)
35
- if (list) queueMicrotask(() => { for (const on of list) on(message) })
35
+ if (list)
36
+ queueMicrotask(() => {
37
+ for (const on of list) on(message)
38
+ })
36
39
  return Promise.resolve()
37
40
  },
38
41
  subscribe(channel, on) {
@@ -12,4 +12,37 @@
12
12
  const CODE = `(n=>{var d=document,c=navigator.connection,q=window.__wq={q:[],n:n,w(){},d:matchMedia("(prefers-reduced-data: reduce)").matches||!!(c&&c.saveData),p(e){var t=e.target,r=q.r,s=e.submitter;if(t.isConnected){q.r=1;try{e.type=="submit"?t.requestSubmit(s&&s.isConnected?s:void 0):e.type=="reset"?t.reset():t.dispatchEvent(new e.constructor(e.type,e))}finally{q.r=r}}}},o=t=>t.closest&&t.closest("[wk]"),h=e=>{var k=!q.r&&o(e.target);if(k&&(!k.$k||q.q.length)&&!((e.metaKey||e.ctrlKey||e.shiftKey)&&e.type=="click"&&e.target.closest("a"))){q.q.push(e);/^(click|submit|reset)$/.test(e.type)&&e.preventDefault();q.w(k)}},l=e=>{var k=o(e.target);k&&!k.$k&&!q.d&&q.w(k,1)};"click dblclick pointerdown pointerup mousedown mouseup keydown keyup input change submit reset focus blur focusin focusout".split(" ").forEach(t=>d.addEventListener(t,h,!0));"pointerover touchstart".split(" ").forEach(t=>d.addEventListener(t,l,{capture:!0,passive:!0}))})`
13
13
 
14
14
  /** The script's text. `now`: the page has work that can't wait, so its code loads straight away. */
15
- export const capture = now => `${CODE}(${now ? 1 : 0})`
15
+ export const capture = (now: boolean) => `${CODE}(${now ? 1 : 0})`
16
+
17
+ /**
18
+ * A page that runs no client until it's needed follows its links with full loads, so the browser prefetches the one the
19
+ * pointer rests on (`moderate`). Not a prerender, which would run the next page's code unseen. An island's links go
20
+ * through the router once its code comes, so they're left out, as is `/_weft/` and anything under `data-preload="off"`;
21
+ * `start()` takes the rules down when the router takes over, so nothing is fetched twice.
22
+ */
23
+ const RULES = JSON.stringify({
24
+ prefetch: [
25
+ {
26
+ source: 'document',
27
+ where: {
28
+ and: [
29
+ { href_matches: '/*' },
30
+ { not: { href_matches: '/_weft/*' } },
31
+ {
32
+ not: {
33
+ selector_matches:
34
+ '[wk] a, a[wk], [data-preload="off"] a, a[data-preload="off"], a[download]',
35
+ },
36
+ },
37
+ ],
38
+ },
39
+ eagerness: 'moderate',
40
+ },
41
+ ],
42
+ })
43
+
44
+ /** The rules, for a page whose code waits (`now` false, nothing streaming). */
45
+ export const speculate = (shape: { now: boolean; streaming?: boolean }) =>
46
+ shape.now || shape.streaming
47
+ ? ''
48
+ : `<script type="speculationrules" id="weft-speculate">${RULES}</script>\n`
@@ -9,6 +9,12 @@ export const LOG_ROWS: number
9
9
  /** Between statements of generated SQL, as Drizzle splits them. */
10
10
  export const BREAK: string
11
11
  /** The trigger statements for these tables, one by one. */
12
- export function changeStatements(dialect: string, tables: readonly { name: string; pk?: string[] }[]): string[]
12
+ export function changeStatements(
13
+ dialect: string,
14
+ tables: readonly { name: string; pk?: string[] }[],
15
+ ): string[]
13
16
  /** The same statements as one migration. */
14
- export function changeSql(dialect: string, tables: readonly { name: string; pk?: string[] }[]): string
17
+ export function changeSql(
18
+ dialect: string,
19
+ tables: readonly { name: string; pk?: string[] }[],
20
+ ): string
@@ -14,10 +14,10 @@ export const MAX_IDS = 32
14
14
  export const LOG_ROWS = 4096
15
15
  export const BREAK = '\n--> statement-breakpoint\n'
16
16
 
17
- const pgName = n => `"${n.replaceAll('"', '""')}"`
18
- const myName = n => `\`${n.replaceAll('`', '``')}\``
19
- const literal = s => `'${s.replaceAll("'", "''")}'`
20
- const safe = t => t.replace(/\W/g, '_')
17
+ const pgName = (n) => `"${n.replaceAll('"', '""')}"`
18
+ const myName = (n) => `\`${n.replaceAll('`', '``')}\``
19
+ const literal = (s) => `'${s.replaceAll("'", "''")}'`
20
+ const safe = (t) => t.replace(/\W/g, '_')
21
21
 
22
22
  function postgres(tables) {
23
23
  const fn = `CREATE OR REPLACE FUNCTION weft_changes_notify() RETURNS trigger AS $weft$
@@ -48,7 +48,12 @@ $weft$ LANGUAGE plpgsql`
48
48
  `DROP TRIGGER IF EXISTS weft_changes_${suffix} ON ${on}`,
49
49
  `CREATE TRIGGER weft_changes_${suffix} AFTER ${event} ON ${on} ${rows ? `REFERENCING ${rows} TABLE AS weft_rows ` : ''}FOR EACH STATEMENT EXECUTE FUNCTION weft_changes_notify(${args})`,
50
50
  ]
51
- return [...trigger('ins', 'INSERT', 'NEW'), ...trigger('upd', 'UPDATE', 'NEW'), ...trigger('del', 'DELETE', 'OLD'), ...trigger('trn', 'TRUNCATE')]
51
+ return [
52
+ ...trigger('ins', 'INSERT', 'NEW'),
53
+ ...trigger('upd', 'UPDATE', 'NEW'),
54
+ ...trigger('del', 'DELETE', 'OLD'),
55
+ ...trigger('trn', 'TRUNCATE'),
56
+ ]
52
57
  })
53
58
  return [fn, ...list]
54
59
  }
@@ -56,7 +61,8 @@ $weft$ LANGUAGE plpgsql`
56
61
  function sqlite(tables) {
57
62
  const log = `CREATE TABLE IF NOT EXISTS ${LOG} (seq INTEGER PRIMARY KEY AUTOINCREMENT, tbl TEXT NOT NULL, pk TEXT)`
58
63
  const list = tables.flatMap(({ name, pk }) => {
59
- const id = row => (pk.length ? pk.map(c => `${row}.${pgName(c)}`).join(` || ':' || `) : 'NULL')
64
+ const id = (row) =>
65
+ pk.length ? pk.map((c) => `${row}.${pgName(c)}`).join(` || ':' || `) : 'NULL'
60
66
  const trigger = (suffix, event, row) => [
61
67
  `DROP TRIGGER IF EXISTS weft_changes_${safe(name)}_${suffix}`,
62
68
  `CREATE TRIGGER weft_changes_${safe(name)}_${suffix} AFTER ${event} ON ${pgName(name)} BEGIN
@@ -64,7 +70,11 @@ function sqlite(tables) {
64
70
  DELETE FROM ${LOG} WHERE seq <= last_insert_rowid() - ${LOG_ROWS};
65
71
  END`,
66
72
  ]
67
- return [...trigger('ins', 'INSERT', 'NEW'), ...trigger('upd', 'UPDATE', 'NEW'), ...trigger('del', 'DELETE', 'OLD')]
73
+ return [
74
+ ...trigger('ins', 'INSERT', 'NEW'),
75
+ ...trigger('upd', 'UPDATE', 'NEW'),
76
+ ...trigger('del', 'DELETE', 'OLD'),
77
+ ]
68
78
  })
69
79
  return [log, ...list]
70
80
  }
@@ -72,12 +82,17 @@ END`,
72
82
  function mysql(tables) {
73
83
  const log = `CREATE TABLE IF NOT EXISTS ${LOG} (seq BIGINT AUTO_INCREMENT PRIMARY KEY, tbl VARCHAR(64) NOT NULL, pk VARCHAR(255), at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY at_idx (at))`
74
84
  const list = tables.flatMap(({ name, pk }) => {
75
- const id = row => (pk.length ? `CONCAT_WS(':', ${pk.map(c => `${row}.${myName(c)}`).join(', ')})` : 'NULL')
85
+ const id = (row) =>
86
+ pk.length ? `CONCAT_WS(':', ${pk.map((c) => `${row}.${myName(c)}`).join(', ')})` : 'NULL'
76
87
  const trigger = (suffix, event, row) => [
77
88
  `DROP TRIGGER IF EXISTS weft_changes_${safe(name)}_${suffix}`,
78
89
  `CREATE TRIGGER weft_changes_${safe(name)}_${suffix} AFTER ${event} ON ${myName(name)} FOR EACH ROW INSERT INTO ${LOG} (tbl, pk) VALUES (${literal(name)}, ${id(row)})`,
79
90
  ]
80
- return [...trigger('ins', 'INSERT', 'NEW'), ...trigger('upd', 'UPDATE', 'NEW'), ...trigger('del', 'DELETE', 'OLD')]
91
+ return [
92
+ ...trigger('ins', 'INSERT', 'NEW'),
93
+ ...trigger('upd', 'UPDATE', 'NEW'),
94
+ ...trigger('del', 'DELETE', 'OLD'),
95
+ ]
81
96
  })
82
97
  return [log, ...list]
83
98
  }
@@ -96,4 +111,5 @@ export function changeStatements(dialect, tables) {
96
111
  }
97
112
 
98
113
  /** A migration file, in drizzle-kit's format. */
99
- export const changeSql = (dialect, tables) => `-- weft: report changes to ${tables.map(t => t.name).join(', ')} (\`weft db live\`). Ids only, never a row.\n${changeStatements(dialect, tables).join(`;${BREAK}`)};\n`
114
+ export const changeSql = (dialect, tables) =>
115
+ `-- weft: report changes to ${tables.map((t) => t.name).join(', ')} (\`weft db live\`). Ids only, never a row.\n${changeStatements(dialect, tables).join(`;${BREAK}`)};\n`