@weft-js/start 0.0.0-stage → 0.1.0
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/LICENSE +16 -0
- package/README.md +41 -2
- package/package.json +107 -4
- package/src/app.mjs +82 -0
- package/src/auth/better-auth.ts +58 -0
- package/src/auth/drizzle.ts +17 -0
- package/src/auth.ts +47 -0
- package/src/boot.ts +44 -0
- package/src/builtin.tsx +19 -0
- package/src/bus/postgres.ts +91 -0
- package/src/bus.ts +48 -0
- package/src/capture.d.mts +1 -0
- package/src/capture.mjs +15 -0
- package/src/changes-sql.d.mts +14 -0
- package/src/changes-sql.mjs +99 -0
- package/src/changes.ts +253 -0
- package/src/channel-fake.ts +55 -0
- package/src/channel.ts +616 -0
- package/src/client.tsx +20 -0
- package/src/contract.mjs +308 -0
- package/src/data.ts +929 -0
- package/src/db/bun-sqlite.d.ts +10 -0
- package/src/db/changes-mysql.ts +86 -0
- package/src/db/changes-postgres.ts +159 -0
- package/src/db/changes-sqlite.ts +116 -0
- package/src/db/core.ts +63 -0
- package/src/db/index.ts +27 -0
- package/src/db/mysql.ts +67 -0
- package/src/db/postgres.ts +65 -0
- package/src/db/sqlite.ts +139 -0
- package/src/derive.d.mts +16 -0
- package/src/derive.mjs +140 -0
- package/src/dev-runtime.mjs +42 -0
- package/src/exec.ts +519 -0
- package/src/extension.ts +38 -0
- package/src/first-paint.ts +57 -0
- package/src/form.tsx +134 -0
- package/src/gate.css +6 -0
- package/src/hub.ts +822 -0
- package/src/index.ts +106 -0
- package/src/inline.mjs +2 -0
- package/src/inspect.mjs +280 -0
- package/src/link.ts +456 -0
- package/src/list-drizzle.ts +50 -0
- package/src/list.ts +224 -0
- package/src/listen.ts +28 -0
- package/src/live-fake.ts +42 -0
- package/src/live-target.mjs +144 -0
- package/src/live.ts +499 -0
- package/src/media.ts +43 -0
- package/src/migrations.mjs +19 -0
- package/src/mux-session.ts +6 -0
- package/src/mux.ts +135 -0
- package/src/og/DefaultCard.tsx +60 -0
- package/src/og/fonts.mjs +64 -0
- package/src/og/images.mjs +54 -0
- package/src/og/index.mjs +146 -0
- package/src/og/native.mjs +29 -0
- package/src/range.mjs +19 -0
- package/src/router.ts +149 -0
- package/src/routes.mjs +142 -0
- package/src/run.tsx +198 -0
- package/src/rust.ts +333 -0
- package/src/server.tsx +1014 -0
- package/src/shape.d.mts +3 -0
- package/src/shape.mjs +73 -0
- package/src/share-server.ts +208 -0
- package/src/share.ts +503 -0
- package/src/sheets.mjs +75 -0
- package/src/sidecar.mjs +503 -0
- package/src/socket.ts +389 -0
- package/src/stream.ts +13 -0
- package/src/verify.ts +251 -0
- package/src/view.tsx +47 -0
- package/src/vite.mjs +899 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ravonus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
6
|
+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
|
7
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
8
|
+
persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
|
11
|
+
Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
14
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
15
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
16
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,42 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @weft-js/start
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Early release (0.1.0).** APIs will change between minor versions.
|
|
4
|
+
|
|
5
|
+
The full-stack part of weft: routes from files, server functions, server rendering with streaming, a database, auth and
|
|
6
|
+
realtime. It runs on Node, Bun or Deno, and doesn't need Rust. An app made with `npm create @weft-js` already has it.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @weft-js/start
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
A server function in `src/server/` runs on the server. The client gets a stub that calls it:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
// src/server/greeting.ts
|
|
16
|
+
import { query } from '@weft-js/start'
|
|
17
|
+
|
|
18
|
+
export const greeting = query({ run: () => `Hello at ${new Date().toISOString()}` })
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
A route in `src/routes/` reads it. Calling the query in a component gives a `Resource`, a box that fills when the data
|
|
22
|
+
arrives:
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
// src/routes/hello.tsx
|
|
26
|
+
import { greeting } from '#/server/greeting'
|
|
27
|
+
|
|
28
|
+
export default function Hello() {
|
|
29
|
+
const text = greeting()
|
|
30
|
+
return <p>{text.value ?? 'Loading…'}</p>
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`npx weft dev` serves it at `/hello`.
|
|
35
|
+
|
|
36
|
+
## Docs
|
|
37
|
+
|
|
38
|
+
- `weft docs start`: routes, `load`, `query`, `mutation`, the database, auth, runtimes and realtime.
|
|
39
|
+
- `weft docs meta`: page metadata and share cards.
|
|
40
|
+
- `weft docs rust`: server functions with a Rust body, if you want them.
|
|
41
|
+
|
|
42
|
+
The source of each page is in [packages/cli/docs](../cli/docs).
|
package/package.json
CHANGED
|
@@ -1,6 +1,109 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weft-js/start",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "weft Start: file routes, server functions, streaming SSR, a database and auth",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@fontsource/inter": "5.3.0",
|
|
9
|
+
"@takumi-rs/core": "2.18.2",
|
|
10
|
+
"@takumi-rs/helpers": "2.18.2",
|
|
11
|
+
"@takumi-rs/wasm": "2.18.2",
|
|
12
|
+
"@tanstack/history": "1.162.5",
|
|
13
|
+
"@tanstack/router-core": "1.171.39",
|
|
14
|
+
"nitro": "3.0.260903-beta",
|
|
15
|
+
"vite": "8.3.5"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"better-auth": "^1.7.0",
|
|
19
|
+
"drizzle-orm": "^0.45.0",
|
|
20
|
+
"drizzle-zod": "^0.8.0",
|
|
21
|
+
"mysql2": "^3.24.0",
|
|
22
|
+
"postgres": ">=3",
|
|
23
|
+
"@weft-js/core": "^0.1.0",
|
|
24
|
+
"zod": "^4.0.0"
|
|
25
|
+
},
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./src/index.ts",
|
|
28
|
+
"./data": "./src/data.ts",
|
|
29
|
+
"./derive": "./src/derive.mjs",
|
|
30
|
+
"./media": "./src/media.ts",
|
|
31
|
+
"./hub": "./src/hub.ts",
|
|
32
|
+
"./live": "./src/stream.ts",
|
|
33
|
+
"./bus": "./src/bus.ts",
|
|
34
|
+
"./changes": "./src/changes.ts",
|
|
35
|
+
"./changes-sql": "./src/changes-sql.mjs",
|
|
36
|
+
"./live-target": "./src/live-target.mjs",
|
|
37
|
+
"./listen": "./src/listen.ts",
|
|
38
|
+
"./verify": "./src/verify.ts",
|
|
39
|
+
"./bus/postgres": "./src/bus/postgres.ts",
|
|
40
|
+
"./list": "./src/list.ts",
|
|
41
|
+
"./list/drizzle": "./src/list-drizzle.ts",
|
|
42
|
+
"./channel": "./src/channel.ts",
|
|
43
|
+
"./share": "./src/share.ts",
|
|
44
|
+
"./db": "./src/db/index.ts",
|
|
45
|
+
"./db/core": "./src/db/core.ts",
|
|
46
|
+
"./db/sqlite": "./src/db/sqlite.ts",
|
|
47
|
+
"./db/postgres": "./src/db/postgres.ts",
|
|
48
|
+
"./db/mysql": "./src/db/mysql.ts",
|
|
49
|
+
"./auth": "./src/auth.ts",
|
|
50
|
+
"./auth/better-auth": "./src/auth/better-auth.ts",
|
|
51
|
+
"./auth/drizzle": "./src/auth/drizzle.ts",
|
|
52
|
+
"./extension": "./src/extension.ts",
|
|
53
|
+
"./client": "./src/client.tsx",
|
|
54
|
+
"./server": "./src/server.tsx",
|
|
55
|
+
"./vite": "./src/vite.mjs",
|
|
56
|
+
"./routes": "./src/routes.mjs",
|
|
57
|
+
"./og": "./src/og/index.mjs",
|
|
58
|
+
"./app": "./src/app.mjs",
|
|
59
|
+
"./inspect": "./src/inspect.mjs",
|
|
60
|
+
"./contract": "./src/contract.mjs",
|
|
61
|
+
"./rust": "./src/rust.ts",
|
|
62
|
+
"./sidecar": "./src/sidecar.mjs"
|
|
63
|
+
},
|
|
64
|
+
"sideEffects": [
|
|
65
|
+
"**/*.css",
|
|
66
|
+
"./src/share.ts"
|
|
67
|
+
],
|
|
68
|
+
"peerDependenciesMeta": {
|
|
69
|
+
"better-auth": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"drizzle-orm": {
|
|
73
|
+
"optional": true
|
|
74
|
+
},
|
|
75
|
+
"drizzle-zod": {
|
|
76
|
+
"optional": true
|
|
77
|
+
},
|
|
78
|
+
"mysql2": {
|
|
79
|
+
"optional": true
|
|
80
|
+
},
|
|
81
|
+
"postgres": {
|
|
82
|
+
"optional": true
|
|
83
|
+
},
|
|
84
|
+
"ioredis": {
|
|
85
|
+
"optional": true
|
|
86
|
+
},
|
|
87
|
+
"@nats-io/transport-node": {
|
|
88
|
+
"optional": true
|
|
89
|
+
},
|
|
90
|
+
"zod": {
|
|
91
|
+
"optional": true
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"publishConfig": {
|
|
95
|
+
"access": "public"
|
|
96
|
+
},
|
|
97
|
+
"engines": {
|
|
98
|
+
"node": ">=26"
|
|
99
|
+
},
|
|
100
|
+
"keywords": [
|
|
101
|
+
"weft"
|
|
102
|
+
],
|
|
103
|
+
"files": [
|
|
104
|
+
"src",
|
|
105
|
+
"crate",
|
|
106
|
+
"README.md",
|
|
107
|
+
"LICENSE"
|
|
108
|
+
]
|
|
109
|
+
}
|
package/src/app.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `weft dev` and `weft build` for an app with src/routes (research 25 §6): Vite 8 with weft() (hydratable output),
|
|
2
|
+
// weftStart() and Nitro's Vite plugin. A vite.config in the app is merged in. The CLI passes the config's `site`.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
5
|
+
import { join, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { createBuilder, createServer } from 'vite'
|
|
8
|
+
import { nitro } from 'nitro/vite'
|
|
9
|
+
import weft from '@weft-js/core/vite'
|
|
10
|
+
import { devRunner, devSockets } from './dev-runtime.mjs'
|
|
11
|
+
import weftStart, { startDir } from './vite.mjs'
|
|
12
|
+
|
|
13
|
+
/** An app with a `channel` or `sharedState`: only those pay for crossws in the server bundle (research 32 §1). */
|
|
14
|
+
function usesSockets(root) {
|
|
15
|
+
const src = join(root, 'src')
|
|
16
|
+
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
|
+
}
|
|
19
|
+
|
|
20
|
+
function config({ root, site, hydration, preset, port, logLevel, command, nitro: nitroOptions = {} }) {
|
|
21
|
+
root = resolve(root)
|
|
22
|
+
const sockets = usesSockets(root)
|
|
23
|
+
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)
|
|
25
|
+
return {
|
|
26
|
+
root,
|
|
27
|
+
configFile: user ?? false,
|
|
28
|
+
logLevel,
|
|
29
|
+
server: { port: port ?? 5173, strictPort: port !== undefined },
|
|
30
|
+
plugins: [
|
|
31
|
+
weft({ hydratable: true }),
|
|
32
|
+
weftStart({ root, site, hydration }),
|
|
33
|
+
runner && devSockets(),
|
|
34
|
+
nitro({
|
|
35
|
+
preset,
|
|
36
|
+
// Every app gets this: built assets ship as .br and .gz next to the file, and Nitro picks by Accept-Encoding.
|
|
37
|
+
compressPublicAssets: { gzip: true, brotli: true },
|
|
38
|
+
...(site?.headers && { routeRules: Object.fromEntries(Object.entries(site.headers).map(([path, headers]) => [path, { headers }])) }),
|
|
39
|
+
...(sockets && { features: { websocket: true } }),
|
|
40
|
+
...(runner && { devServer: { runner } }),
|
|
41
|
+
// Build mode for share cards (research 20 §4.3): the server lists them and Nitro's prerender writes the PNGs.
|
|
42
|
+
prerender: { routes: ['/_weft/og-routes'], failOnError: true },
|
|
43
|
+
// Prerendered cards are static files at content-addressed URLs: immutable. The rule names each file (a
|
|
44
|
+
// `/_weft/og/**` rule would also override the handler's no-cache for a stale `?v=` on demand). Prerender runs
|
|
45
|
+
// 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))] }),
|
|
47
|
+
// 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*'] }),
|
|
49
|
+
...nitroOptions,
|
|
50
|
+
}),
|
|
51
|
+
],
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function immutableCards() {
|
|
56
|
+
let built
|
|
57
|
+
return {
|
|
58
|
+
'prerender:generate'(_route, instance) { built = instance },
|
|
59
|
+
'prerender:done'({ prerenderedRoutes }) {
|
|
60
|
+
if (!built) return
|
|
61
|
+
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' } }
|
|
63
|
+
}
|
|
64
|
+
built.routing.sync()
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Start the dev server; resolves with it once it listens. */
|
|
70
|
+
export async function dev(options) {
|
|
71
|
+
const server = await createServer(config(options))
|
|
72
|
+
await server.listen()
|
|
73
|
+
return server
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Build client and server into `.output/` for `preset` (node by default; bun, deno_server…). */
|
|
77
|
+
export async function build(options) {
|
|
78
|
+
process.env.WEFT_START_DIR ??= startDir // for the share cards a build draws (og/native.mjs)
|
|
79
|
+
const builder = await createBuilder(config({ ...options, command: 'build' }))
|
|
80
|
+
await builder.buildApp()
|
|
81
|
+
return { output: join(resolve(options.root), '.output') }
|
|
82
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Better Auth as weft's default auth provider (research 28 §3, 34 §5). `src/auth.server.ts` default-exports `createAuth(…)`.
|
|
2
|
+
// No client code: sign-in is a mutation, so nothing of Better Auth ships to the browser.
|
|
3
|
+
|
|
4
|
+
import { betterAuth, type BetterAuthOptions } from 'better-auth'
|
|
5
|
+
import { CLIENT_IP, StartError } from '../data'
|
|
6
|
+
import type { DbHandle } from '../db/core'
|
|
7
|
+
|
|
8
|
+
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('')
|
|
10
|
+
|
|
11
|
+
/** `createAuth`: the database, the secret, and any Better Auth option on top. */
|
|
12
|
+
export interface AuthOptions<S extends Record<string, unknown>, O extends BetterAuthOptions> {
|
|
13
|
+
db: { $weft: DbHandle }
|
|
14
|
+
/** The tables, Better Auth's included, for a data layer that needs them. */
|
|
15
|
+
schema?: S
|
|
16
|
+
/** 32+ characters (A001 in production). */
|
|
17
|
+
secret?: string
|
|
18
|
+
/** The rest of Better Auth's options; they win over the defaults here. */
|
|
19
|
+
options?: O
|
|
20
|
+
}
|
|
21
|
+
|
|
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>) {
|
|
24
|
+
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')
|
|
27
|
+
const auth = betterAuth({
|
|
28
|
+
// A build loads this module with no environment, and in production Better Auth refuses to start without a secret: the
|
|
29
|
+
// build gets one of its own, since nothing it makes is signed. A server still needs the real one (A001).
|
|
30
|
+
secret: secret ?? (process.env.WEFT_BUILD ? buildSecret() : undefined),
|
|
31
|
+
// A build's prerender holds no environment; a server takes `BETTER_AUTH_URL`, or the request's origin.
|
|
32
|
+
...(process.env.WEFT_BUILD && { baseURL: 'http://localhost' }),
|
|
33
|
+
telemetry: { enabled: false },
|
|
34
|
+
emailAndPassword: { enabled: true },
|
|
35
|
+
...options,
|
|
36
|
+
// The server stamps this header from the socket (server.tsx), so rate limits aren't one bucket for everyone.
|
|
37
|
+
// Its schema check runs when this module loads. On Kysely that connects, in a build, and before a fresh database has
|
|
38
|
+
// 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 } },
|
|
40
|
+
})
|
|
41
|
+
return Object.assign(auth, {
|
|
42
|
+
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
|
+
},
|
|
49
|
+
$weft: {
|
|
50
|
+
/** On the first request (never in a build, which holds no secrets). */
|
|
51
|
+
check() {
|
|
52
|
+
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')
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Better Auth on a Drizzle client, as `options.database`. It's an entry of its own so that a Drizzle app without auth
|
|
2
|
+
// needs no better-auth, and an app on another data layer needs no drizzle-orm.
|
|
3
|
+
|
|
4
|
+
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
|
5
|
+
import { getTableName, isTable } from 'drizzle-orm'
|
|
6
|
+
import type { DbHandle } from '../db/core'
|
|
7
|
+
|
|
8
|
+
const PROVIDER = { sqlite: 'sqlite', postgresql: 'pg', mysql: 'mysql' } as const
|
|
9
|
+
|
|
10
|
+
/** Better Auth's store on a Weft database, for `createAuth`'s `options.database`. */
|
|
11
|
+
export function drizzleAuth(db: { $weft: DbHandle }, schema?: Record<string, unknown>) {
|
|
12
|
+
for (const name of ['user', 'session', 'account', 'verification']) {
|
|
13
|
+
const t = schema?.[name]
|
|
14
|
+
if (isTable(t)) db.$weft.exempt.add(getTableName(t))
|
|
15
|
+
}
|
|
16
|
+
return drizzleAdapter(db, { provider: PROVIDER[db.$weft.dialect], schema })
|
|
17
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// `@weft-js/start/auth`: what weft needs from any auth provider (research 34 §5). A provider is `src/auth.server.ts`'s default
|
|
2
|
+
// export: `handler` serves its routes under `basePath`, and `session` says who a request is. Nothing here knows Better
|
|
3
|
+
// Auth (`@weft-js/start/auth/better-auth`, the default), so a hand-written provider has no private path to work around.
|
|
4
|
+
|
|
5
|
+
import { FnError, type Context } from './data'
|
|
6
|
+
|
|
7
|
+
/** Where a session comes from: Better Auth by default, or anything with a `handler` and a `session`. */
|
|
8
|
+
export interface AuthProvider<U = unknown, S = unknown> {
|
|
9
|
+
/** Serves everything under `basePath`. */
|
|
10
|
+
handler(request: Request): Promise<Response> | Response
|
|
11
|
+
/** Who this request is. Anonymous is `{ user: null, session: null }`, and should cost no query. */
|
|
12
|
+
session(request: Request): Promise<{ user: U | null; session: S | null }>
|
|
13
|
+
/** Where `handler` is mounted (default `/api/auth`). */
|
|
14
|
+
basePath?: string
|
|
15
|
+
$weft?: {
|
|
16
|
+
/** On the first request, never in a build: throw for a server that can't run safely. */
|
|
17
|
+
check(): void
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A provider you wrote: checks its shape, and types `ctx.user` from your `session`. */
|
|
22
|
+
export function defineAuth<U, S>(provider: AuthProvider<U, S>): AuthProvider<U, S> {
|
|
23
|
+
return provider
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `{ 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']>
|
|
28
|
+
|
|
29
|
+
const changed = new WeakSet<Headers>()
|
|
30
|
+
|
|
31
|
+
/** `forward` put a provider's cookie on this response, so who's signed in may have changed. */
|
|
32
|
+
export const sessionChanged = (headers: Headers) => changed.has(headers)
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Finish a provider's Response (Better Auth's with `asResponse: true`): its cookies go on this response, so a redirect
|
|
36
|
+
* after it is a full load, and a failure rejects with the provider's message (`e.message` on the client).
|
|
37
|
+
*/
|
|
38
|
+
export async function forward<T = unknown>(ctx: Context, call: Response | Promise<Response>): Promise<T> {
|
|
39
|
+
const res = await call
|
|
40
|
+
for (const c of res.headers.getSetCookie()) {
|
|
41
|
+
ctx.response.headers.append('set-cookie', c)
|
|
42
|
+
changed.add(ctx.response.headers)
|
|
43
|
+
}
|
|
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)
|
|
46
|
+
return body as T
|
|
47
|
+
}
|
package/src/boot.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// All a page that's only being read runs (PLAN §11). It starts `full` (the router, the runtime) when the page needs code at once, when something happens on an island, or when an island that
|
|
2
|
+
// wants to be running, or that isn't in the first view and so wasn't preloaded, comes into view. A visitor who asked for
|
|
3
|
+
// less data gets it on interaction only. `#weft-route` and the islands' comments are read by `start()` once it's here.
|
|
4
|
+
|
|
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 }
|
|
7
|
+
|
|
8
|
+
export function boot(full: () => Promise<unknown>) {
|
|
9
|
+
const q = (window as Window & { __wq?: Held }).__wq
|
|
10
|
+
let io: IntersectionObserver | undefined // oxlint-disable-line prefer-const -- `go` reads it, and can run before it's set
|
|
11
|
+
const go = () => {
|
|
12
|
+
if (q) q.w = () => {}
|
|
13
|
+
io?.disconnect()
|
|
14
|
+
full().catch(() => {
|
|
15
|
+
// No code is coming: what's held takes its default.
|
|
16
|
+
if (!q) return
|
|
17
|
+
q.r = 1
|
|
18
|
+
q.q.splice(0).forEach(q.p)
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
if (!q || q.n || q.q.length) return go()
|
|
22
|
+
q.w = (k: Element, lead?: 1) => {
|
|
23
|
+
if (lead) q.l = k
|
|
24
|
+
go()
|
|
25
|
+
}
|
|
26
|
+
if (q.d) return
|
|
27
|
+
const first = new WeakSet<Element>()
|
|
28
|
+
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' })
|
|
36
|
+
const w = document.createTreeWalker(document.body, 128)
|
|
37
|
+
while (w.nextNode()) {
|
|
38
|
+
const c = w.currentNode as Comment
|
|
39
|
+
const el = c.data.startsWith('wi:') && c.nextElementSibling
|
|
40
|
+
if (!el) continue
|
|
41
|
+
if (JSON.parse(c.data.slice(3))[3] === 'v') needs.add(el)
|
|
42
|
+
io.observe(el)
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/builtin.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The 404 and error pages an app gets until it adds `src/routes/404.tsx` or `src/routes/_error.tsx`.
|
|
2
|
+
|
|
3
|
+
export function NotFoundPage(props: { data: { path?: string } }) {
|
|
4
|
+
return (
|
|
5
|
+
<main>
|
|
6
|
+
<h1>Not found</h1>
|
|
7
|
+
<p>Nothing lives at {props.data?.path}.</p>
|
|
8
|
+
</main>
|
|
9
|
+
)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ErrorPage(props: { data: { status: number; message: string } }) {
|
|
13
|
+
return (
|
|
14
|
+
<main>
|
|
15
|
+
<h1>Something went wrong</h1>
|
|
16
|
+
<p>{props.data?.message}</p>
|
|
17
|
+
</main>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// LISTEN/NOTIFY (research 30 §3): one dedicated connection and one Postgres channel, with the logical channel in the
|
|
2
|
+
// payload, because Postgres identifiers stop at 63 bytes and tags don't. NOTIFY stops at 8,000 bytes, so a longer
|
|
3
|
+
// message goes base64 in numbered chunks inside one transaction, which Postgres delivers together and in order.
|
|
4
|
+
|
|
5
|
+
import postgres from 'postgres'
|
|
6
|
+
import type { Bus } from '../bus'
|
|
7
|
+
import { StartError } from '../data'
|
|
8
|
+
|
|
9
|
+
/** `postgresBus`: the database to send NOTIFY through. */
|
|
10
|
+
export interface PostgresBusOptions {
|
|
11
|
+
/** `postgres://…`, direct: pgBouncer in transaction mode breaks LISTEN. */
|
|
12
|
+
url: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CHANNEL = 'weft_bus'
|
|
16
|
+
const WHOLE = 7_900
|
|
17
|
+
const CHUNK = 7_800
|
|
18
|
+
|
|
19
|
+
/** A bus on Postgres NOTIFY, for several instances with no broker beside the database. */
|
|
20
|
+
export function postgresBus({ url }: PostgresBusOptions): Bus {
|
|
21
|
+
if (!url && process.env.WEFT_BUILD) url = 'postgres://localhost/build' // a build loads this module and never connects
|
|
22
|
+
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')
|
|
24
|
+
let sql: ReturnType<typeof postgres> | undefined
|
|
25
|
+
const client = () => (sql ??= postgres(url, { max: 2, idle_timeout: 20, onnotice: () => {} }))
|
|
26
|
+
const listeners = new Map<string, Set<(message: string) => void>>()
|
|
27
|
+
const gaps = new Set<() => void>()
|
|
28
|
+
const parts = new Map<string, string[]>()
|
|
29
|
+
let listening: Promise<unknown> | undefined
|
|
30
|
+
let connected = false
|
|
31
|
+
|
|
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
|
+
}
|
|
37
|
+
const received = (payload: string) => {
|
|
38
|
+
if (payload[0] === '=') return deliver(payload.slice(1))
|
|
39
|
+
const [id, i, n, body] = payload.slice(1).split(':')
|
|
40
|
+
if (id === undefined || i === undefined || n === undefined || body === undefined) return undefined
|
|
41
|
+
const got = parts.get(id) ?? []
|
|
42
|
+
got[+i] = body
|
|
43
|
+
parts.set(id, got)
|
|
44
|
+
if (Object.keys(got).length < +n) return undefined
|
|
45
|
+
parts.delete(id)
|
|
46
|
+
deliver(Buffer.from(got.join(''), 'base64').toString())
|
|
47
|
+
}
|
|
48
|
+
/** `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
|
+
}))
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
name: 'postgres',
|
|
62
|
+
async publish(channel, message) {
|
|
63
|
+
const text = `${channel}\n${message}`
|
|
64
|
+
if (Buffer.byteLength(text) <= WHOLE) return void (await client().notify(CHANNEL, '=' + text))
|
|
65
|
+
const b64 = Buffer.from(text).toString('base64')
|
|
66
|
+
const id = crypto.randomUUID()
|
|
67
|
+
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)}`})`
|
|
70
|
+
})
|
|
71
|
+
},
|
|
72
|
+
async subscribe(channel, on, gap) {
|
|
73
|
+
let list = listeners.get(channel)
|
|
74
|
+
if (!list) listeners.set(channel, (list = new Set()))
|
|
75
|
+
list.add(on)
|
|
76
|
+
if (gap) gaps.add(gap)
|
|
77
|
+
await listen()
|
|
78
|
+
return () => {
|
|
79
|
+
list.delete(on)
|
|
80
|
+
if (!list.size) listeners.delete(channel)
|
|
81
|
+
if (gap) gaps.delete(gap)
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
async close() {
|
|
85
|
+
await sql?.end({ timeout: 1 })
|
|
86
|
+
sql = undefined
|
|
87
|
+
listening = undefined
|
|
88
|
+
connected = false
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/bus.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The fan-out bus (research 30 §3): strings, at-most-once, ordered per publisher. Tags, topics, rings and cursors stay
|
|
2
|
+
// in the hub, so a bus is three methods. `@weft-js/realtime-redis`, `-nats` and `-durable-object` implement this interface.
|
|
3
|
+
|
|
4
|
+
/** How one instance reaches the others: the in-memory bus for one process, a plugin's for many. */
|
|
5
|
+
export interface Bus {
|
|
6
|
+
readonly name: string
|
|
7
|
+
publish(channel: string, message: string): Promise<void>
|
|
8
|
+
/**
|
|
9
|
+
* Resolves once listening, with the way to stop. `gap` means messages may have been lost (a dropped connection):
|
|
10
|
+
* the hub treats everything before it as out of the resume window.
|
|
11
|
+
*/
|
|
12
|
+
subscribe(channel: string, on: (message: string) => void, gap?: () => void): Promise<() => void>
|
|
13
|
+
close?(): Promise<void>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The bus a `realtime.bus` plugin provides, loaded on the first publish or subscribe: `weft build` reaches for neither. */
|
|
17
|
+
export function slotBus(plugin: string, load: () => Promise<Bus>): Bus {
|
|
18
|
+
let bus: Promise<Bus> | undefined
|
|
19
|
+
const get = () => (bus ??= load())
|
|
20
|
+
return {
|
|
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()),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One process (PLAN §4.2). Delivered in a microtask: no code may rely on a fan-out no other bus has. */
|
|
29
|
+
export function memoryBus(): Bus {
|
|
30
|
+
const channels = new Map<string, Set<(message: string) => void>>()
|
|
31
|
+
return {
|
|
32
|
+
name: 'memory',
|
|
33
|
+
publish(channel, message) {
|
|
34
|
+
const list = channels.get(channel)
|
|
35
|
+
if (list) queueMicrotask(() => { for (const on of list) on(message) })
|
|
36
|
+
return Promise.resolve()
|
|
37
|
+
},
|
|
38
|
+
subscribe(channel, on) {
|
|
39
|
+
let list = channels.get(channel)
|
|
40
|
+
if (!list) channels.set(channel, (list = new Set()))
|
|
41
|
+
list.add(on)
|
|
42
|
+
return Promise.resolve(() => {
|
|
43
|
+
list.delete(on)
|
|
44
|
+
if (!list.size) channels.delete(channel)
|
|
45
|
+
})
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const capture: (now: boolean) => string
|
package/src/capture.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// What every page of an app with lazy islands carries inline, so nothing done before the code arrives is lost (PLAN §11). An element
|
|
2
|
+
// with `wk` that hasn't been claimed (no `$k`) belongs to code that isn't there: its events go in `q` in order, and
|
|
3
|
+
// `start()` replays them. A click, submit or reset waits with its default held, and the replay is what acts, so a page
|
|
4
|
+
// whose code never comes just runs the defaults late. Everything else (typing, focus) has already happened in the DOM.
|
|
5
|
+
// A modified click on a link is the browser's (a new tab), and stays out of it.
|
|
6
|
+
//
|
|
7
|
+
// q the events n the page needs its code at once w wake: (element, lead), a lead being only a hint of intent
|
|
8
|
+
// p replay one r replaying, so don't capture d the visitor asked for less data l the last lead
|
|
9
|
+
//
|
|
10
|
+
// Written by hand and small, since every such page pays it: `boot.ts` and weft's `island.ts` are the other two halves.
|
|
11
|
+
|
|
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
|
+
|
|
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})`
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** The NOTIFY channel Postgres triggers send on. */
|
|
2
|
+
export const CHANNEL: string
|
|
3
|
+
/** The log table SQLite and MySQL triggers write to. */
|
|
4
|
+
export const LOG: string
|
|
5
|
+
/** More ids than this in one statement is a table-level change. */
|
|
6
|
+
export const MAX_IDS: number
|
|
7
|
+
/** Rows SQLite's log keeps; a reader further behind pokes every table. */
|
|
8
|
+
export const LOG_ROWS: number
|
|
9
|
+
/** Between statements of generated SQL, as Drizzle splits them. */
|
|
10
|
+
export const BREAK: string
|
|
11
|
+
/** The trigger statements for these tables, one by one. */
|
|
12
|
+
export function changeStatements(dialect: string, tables: readonly { name: string; pk?: string[] }[]): string[]
|
|
13
|
+
/** The same statements as one migration. */
|
|
14
|
+
export function changeSql(dialect: string, tables: readonly { name: string; pk?: string[] }[]): string
|