@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/app.mjs CHANGED
@@ -8,25 +8,46 @@ import { createBuilder, createServer } from 'vite'
8
8
  import { nitro } from 'nitro/vite'
9
9
  import weft from '@weft-js/core/vite'
10
10
  import { devRunner, devSockets } from './dev-runtime.mjs'
11
+ import { holdsSockets } from './live-target.mjs'
11
12
  import weftStart, { startDir } from './vite.mjs'
12
13
 
13
14
  /** An app with a `channel` or `sharedState`: only those pay for crossws in the server bundle (research 32 §1). */
14
15
  function usesSockets(root) {
15
16
  const src = join(root, 'src')
16
17
  if (!existsSync(src)) return false
17
- return readdirSync(src, { recursive: true, withFileTypes: true }).some(f => f.isFile() && /\.[cm]?[jt]sx?$/.test(f.name) && /\b(channel|sharedState)\s*[(<]/.test(readFileSync(join(f.parentPath, f.name), 'utf8')))
18
+ return readdirSync(src, { recursive: true, withFileTypes: true }).some(
19
+ (f) =>
20
+ f.isFile() &&
21
+ /\.[cm]?[jt]sx?$/.test(f.name) &&
22
+ /\b(channel|sharedState)\s*[(<]/.test(readFileSync(join(f.parentPath, f.name), 'utf8')),
23
+ )
18
24
  }
19
25
 
20
- function config({ root, site, hydration, preset, port, logLevel, command, nitro: nitroOptions = {} }) {
26
+ function config({
27
+ root,
28
+ site,
29
+ hydration,
30
+ preset,
31
+ port,
32
+ logLevel,
33
+ command,
34
+ nitro: nitroOptions = {},
35
+ }) {
21
36
  root = resolve(root)
22
37
  const sockets = usesSockets(root)
23
38
  const runner = devRunner(sockets)
24
- const user = ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs'].map(f => join(root, f)).find(existsSync)
39
+ const user = ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']
40
+ .map((f) => join(root, f))
41
+ .find(existsSync)
25
42
  return {
26
43
  root,
27
44
  configFile: user ?? false,
28
45
  logLevel,
29
46
  server: { port: port ?? 5173, strictPort: port !== undefined },
47
+ // A function host can't hold a socket: the page goes straight to SSE, and a channel says `unavailable` (R024)
48
+ ...(command === 'build' && {
49
+ define: { __WEFT_SOCKETS__: JSON.stringify(holdsSockets(preset)) },
50
+ }),
30
51
  plugins: [
31
52
  weft({ hydratable: true }),
32
53
  weftStart({ root, site, hydration }),
@@ -35,7 +56,11 @@ function config({ root, site, hydration, preset, port, logLevel, command, nitro:
35
56
  preset,
36
57
  // Every app gets this: built assets ship as .br and .gz next to the file, and Nitro picks by Accept-Encoding.
37
58
  compressPublicAssets: { gzip: true, brotli: true },
38
- ...(site?.headers && { routeRules: Object.fromEntries(Object.entries(site.headers).map(([path, headers]) => [path, { headers }])) }),
59
+ ...(site?.headers && {
60
+ routeRules: Object.fromEntries(
61
+ Object.entries(site.headers).map(([path, headers]) => [path, { headers }]),
62
+ ),
63
+ }),
39
64
  ...(sockets && { features: { websocket: true } }),
40
65
  ...(runner && { devServer: { runner } }),
41
66
  // Build mode for share cards (research 20 §4.3): the server lists them and Nitro's prerender writes the PNGs.
@@ -43,23 +68,38 @@ function config({ root, site, hydration, preset, port, logLevel, command, nitro:
43
68
  // Prerendered cards are static files at content-addressed URLs: immutable. The rule names each file (a
44
69
  // `/_weft/og/**` rule would also override the handler's no-cache for a stale `?v=` on demand). Prerender runs
45
70
  // before Nitro bundles the server, so rules added when it is done are in the build. (Dev sends no-store.)
46
- ...(command === 'build' && { hooks: immutableCards(), plugins: [fileURLToPath(new URL('./range.mjs', import.meta.url))] }),
71
+ ...(command === 'build' && {
72
+ hooks: immutableCards(),
73
+ plugins: [fileURLToPath(new URL('./range.mjs', import.meta.url))],
74
+ }),
47
75
  // The media route: sharp's addon, and css-tree and csso (under svgo) read JSON from their packages at runtime.
48
- ...(existsSync(join(root, '.weft/slots/media.server.ts')) && { traceDeps: ['css-tree*', 'csso*', 'sharp*'] }),
76
+ ...(existsSync(join(root, '.weft/slots/media.server.ts')) && {
77
+ traceDeps: ['css-tree*', 'csso*', 'sharp*'],
78
+ }),
49
79
  ...nitroOptions,
50
80
  }),
51
81
  ],
52
82
  }
53
83
  }
54
84
 
85
+ /** Where the last build's Nitro wrote, known once it prerenders: a preset may move it (netlify serves `dist`). */
86
+ let wrote
87
+
55
88
  function immutableCards() {
56
89
  let built
57
90
  return {
58
- 'prerender:generate'(_route, instance) { built = instance },
91
+ 'prerender:generate'(_route, instance) {
92
+ built = instance
93
+ const { dir, publicDir } = instance.options.output
94
+ wrote = publicDir.startsWith(dir) ? dir : publicDir
95
+ },
59
96
  'prerender:done'({ prerenderedRoutes }) {
60
97
  if (!built) return
61
98
  for (const r of prerenderedRoutes) {
62
- if (r.route.startsWith('/_weft/og/') && r.route.endsWith('.png')) built.options.routeRules[r.route] = { headers: { 'cache-control': 'public, max-age=31536000, immutable' } }
99
+ if (r.route.startsWith('/_weft/og/') && r.route.endsWith('.png'))
100
+ built.options.routeRules[r.route] = {
101
+ headers: { 'cache-control': 'public, max-age=31536000, immutable' },
102
+ }
63
103
  }
64
104
  built.routing.sync()
65
105
  },
@@ -73,10 +113,11 @@ export async function dev(options) {
73
113
  return server
74
114
  }
75
115
 
76
- /** Build client and server into `.output/` for `preset` (node by default; bun, deno_server…). */
116
+ /** Build client and server for `preset` (node by default; bun, deno_server…), into `.output/` unless the preset moves it. */
77
117
  export async function build(options) {
78
118
  process.env.WEFT_START_DIR ??= startDir // for the share cards a build draws (og/native.mjs)
79
119
  const builder = await createBuilder(config({ ...options, command: 'build' }))
120
+ wrote = undefined
80
121
  await builder.buildApp()
81
- return { output: join(resolve(options.root), '.output') }
122
+ return { output: wrote ?? join(resolve(options.root), '.output') }
82
123
  }
@@ -0,0 +1,13 @@
1
+ import type { AssetReceipt, RouteAssetCosts } from './router'
2
+
3
+ /** Select only matched levels and union their emitted files for this build. */
4
+ export function selectRouteAssets(
5
+ receipt: AssetReceipt | null | undefined,
6
+ build: string | undefined,
7
+ ids: string[],
8
+ ): RouteAssetCosts | null
9
+ /** The costs, if they were measured for this build. */
10
+ export function matchingRouteAssets(
11
+ costs: RouteAssetCosts | undefined,
12
+ build: string | null | undefined,
13
+ ): RouteAssetCosts | null
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Select only matched levels and union their emitted files for this build.
3
+ * @param {import('./router').AssetReceipt | null | undefined} receipt
4
+ * @param {string | undefined} build
5
+ * @param {string[]} ids
6
+ * @returns {import('./router').RouteAssetCosts | null}
7
+ */
8
+ export function selectRouteAssets(receipt, build, ids) {
9
+ if (
10
+ !build ||
11
+ !receipt ||
12
+ receipt.build !== build ||
13
+ receipt.version !== 1 ||
14
+ receipt.scope !== 'vite-static-closure' ||
15
+ !receipt.routes ||
16
+ !receipt.files ||
17
+ !ids.length
18
+ )
19
+ return null
20
+ const levels = ids.map((id) => ({
21
+ id,
22
+ entry: Object.hasOwn(receipt.routes, id) ? receipt.routes[id] : null,
23
+ }))
24
+ const union = (entries) => {
25
+ const names = new Set()
26
+ for (const entry of entries) {
27
+ if (
28
+ !entry ||
29
+ !Array.isArray(entry.files) ||
30
+ typeof entry.bytes !== 'number' ||
31
+ !Number.isSafeInteger(entry.bytes) ||
32
+ entry.bytes < 0
33
+ )
34
+ return null
35
+ for (const file of entry.files) names.add(file)
36
+ }
37
+ let total = 0
38
+ for (const file of names) {
39
+ const bytes = Object.hasOwn(receipt.files, file) ? receipt.files[file]?.bytes : null
40
+ if (typeof bytes !== 'number' || !Number.isSafeInteger(bytes) || bytes < 0) return null
41
+ total += bytes
42
+ if (!Number.isSafeInteger(total)) return null
43
+ }
44
+ return total
45
+ }
46
+ return {
47
+ build,
48
+ scope: receipt.scope,
49
+ levels: levels.map(({ id, entry }) => ({ id, bytes: union([entry]) })),
50
+ routeBytes: union(levels.map(({ entry }) => entry)),
51
+ eagerBytes: union([receipt.eager]),
52
+ combinedBytes: union([...levels.map(({ entry }) => entry), receipt.eager]),
53
+ }
54
+ }
55
+
56
+ /** @param {import('./router').RouteAssetCosts | undefined} costs
57
+ * @param {string | null | undefined} build
58
+ * @returns {import('./router').RouteAssetCosts | null} */
59
+ export function matchingRouteAssets(costs, build) {
60
+ return costs?.build && costs.build === build ? costs : null
61
+ }
@@ -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) {