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