@weft-js/start 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1278 -12
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -0
- package/crate/README.md +322 -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 +38 -8
- 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 +120 -492
- package/src/client.tsx +9 -2
- package/src/contract/describe.mjs +231 -0
- package/src/contract.mjs +144 -157
- 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 +90 -166
- 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 +112 -37
- 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 -178
- 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 -971
- 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/hub/feed.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createFeed, functionHost, type Feed, type Probe, type Source } from '../changes'
|
|
2
|
+
import { StartError } from '../data'
|
|
3
|
+
import { defaultStore } from '../exec'
|
|
4
|
+
import type { Channel, Conn, HubOptions, Limits, LiveState } from './types'
|
|
5
|
+
|
|
6
|
+
const proc = globalThis as unknown as {
|
|
7
|
+
process?: { env: Record<string, string | undefined>; exit(code: number): void }
|
|
8
|
+
}
|
|
9
|
+
const unref = <T>(t: T) => ((t as { unref?(): void } | null | undefined)?.unref?.(), t)
|
|
10
|
+
interface FeedState {
|
|
11
|
+
options: HubOptions
|
|
12
|
+
limits: Limits
|
|
13
|
+
conns: Map<string, Conn>
|
|
14
|
+
pokes: () => Channel
|
|
15
|
+
source: Source | undefined
|
|
16
|
+
live: LiveState
|
|
17
|
+
refused: StartError | undefined
|
|
18
|
+
feed: Feed | undefined
|
|
19
|
+
probed: Promise<unknown>
|
|
20
|
+
waking: Promise<unknown> | undefined
|
|
21
|
+
resting: ReturnType<typeof setTimeout> | undefined
|
|
22
|
+
beats: ReturnType<typeof setInterval> | undefined
|
|
23
|
+
beat: number
|
|
24
|
+
}
|
|
25
|
+
function report(state: FeedState, code: string, severity: string, message: string) {
|
|
26
|
+
state.options.onDiagnostic?.({ code, severity, message })
|
|
27
|
+
}
|
|
28
|
+
function wire(state: FeedState) {
|
|
29
|
+
return { m: state.live.mode, ...(state.live.down && { d: 1 }) }
|
|
30
|
+
}
|
|
31
|
+
function status(state: FeedState, down: string | undefined) {
|
|
32
|
+
if (state.live.down === down) return
|
|
33
|
+
state.live = { ...state.live, down }
|
|
34
|
+
if (down) report(state, 'R016', 'warning', `live: ${down}`)
|
|
35
|
+
for (const c of state.conns.values()) c.sink.write('L', JSON.stringify(wire(state)))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Probes rather than assumes (`what works here`), says what it found once, and refuses to serve what was required. */
|
|
39
|
+
async function settle(state: FeedState, src: Source) {
|
|
40
|
+
const { options } = state
|
|
41
|
+
const host = functionHost()
|
|
42
|
+
const how: Probe = src.external
|
|
43
|
+
? { mode: 'feed', via: 'listener' }
|
|
44
|
+
: host
|
|
45
|
+
? {
|
|
46
|
+
mode: 'off',
|
|
47
|
+
via: 'none',
|
|
48
|
+
why: `${host} ends a process with its request, so nothing can hold a database connection`,
|
|
49
|
+
fix: "run `weft db listen` on an always-on host and set `listener: 'external'`, or deploy to a server preset",
|
|
50
|
+
}
|
|
51
|
+
: await src.probe().catch((e) => ({
|
|
52
|
+
mode: 'off' as const,
|
|
53
|
+
via: 'none',
|
|
54
|
+
why: e instanceof Error ? e.message : String(e),
|
|
55
|
+
fix: 'check the database',
|
|
56
|
+
}))
|
|
57
|
+
state.live = {
|
|
58
|
+
mode: src.external ? 'external' : how.mode,
|
|
59
|
+
via: how.via,
|
|
60
|
+
why: how.why,
|
|
61
|
+
fix: how.fix,
|
|
62
|
+
}
|
|
63
|
+
const line = `live: ${state.live.mode} (${state.live.via})${how.why ? `: ${how.why}` : ''}`
|
|
64
|
+
if (how.mode === 'feed') console.info(`weft ${line}`)
|
|
65
|
+
else {
|
|
66
|
+
console.warn(`weft ${line}${how.fix ? `\n fix: ${how.fix}` : ''}`)
|
|
67
|
+
report(state, 'R016', 'warning', `${line}. \`.live()\` shows \`degraded\``)
|
|
68
|
+
}
|
|
69
|
+
if (how.mode === 'feed' || !options.required?.length) return how
|
|
70
|
+
state.refused = new StartError(
|
|
71
|
+
'R015',
|
|
72
|
+
`${options.required.map((n) => `\`${n}\``).join(', ')} require${options.required.length > 1 ? '' : 's'} a live feed, and this server has ${how.mode === 'poll' ? 'only polling' : 'none'}${how.why ? `: ${how.why}` : ''}`,
|
|
73
|
+
how.fix ?? 'see `weft doctor`',
|
|
74
|
+
{ status: 503 },
|
|
75
|
+
)
|
|
76
|
+
console.error(`weft ${state.refused.message}`)
|
|
77
|
+
if (!options.dev) proc.process?.exit(1)
|
|
78
|
+
return how
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The feed runs while someone can use it: a server with no clients holds no listener and runs no watcher. */
|
|
82
|
+
function want(state: FeedState) {
|
|
83
|
+
const { source, limits, pokes } = state
|
|
84
|
+
clearTimeout(state.resting)
|
|
85
|
+
state.resting = undefined
|
|
86
|
+
if (!source) return
|
|
87
|
+
if (source.external) {
|
|
88
|
+
if (state.beats) return
|
|
89
|
+
state.beat = Date.now()
|
|
90
|
+
void pokes()
|
|
91
|
+
state.beats = unref(
|
|
92
|
+
setInterval(() => {
|
|
93
|
+
if (Date.now() - state.beat > limits.window / 2)
|
|
94
|
+
status(state, 'the external listener has gone quiet')
|
|
95
|
+
}, limits.heartbeat / 2),
|
|
96
|
+
)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
return (state.waking ??= state.probed
|
|
100
|
+
.then((how) => state.feed!.start(how as Probe))
|
|
101
|
+
.catch((e) => status(state, e instanceof Error ? e.message : String(e)))
|
|
102
|
+
.finally(() => {
|
|
103
|
+
state.waking = undefined
|
|
104
|
+
}))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function rest(state: FeedState) {
|
|
108
|
+
const { source, conns, feed, limits } = state
|
|
109
|
+
if (!source || conns.size || state.resting || !feed?.running) return
|
|
110
|
+
state.resting = unref(
|
|
111
|
+
setTimeout(() => {
|
|
112
|
+
state.resting = undefined
|
|
113
|
+
if (!conns.size) void feed.stop()
|
|
114
|
+
}, limits.window),
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function createFeedState(
|
|
119
|
+
options: HubOptions,
|
|
120
|
+
limits: Limits,
|
|
121
|
+
conns: Map<string, Conn>,
|
|
122
|
+
pokes: () => Channel,
|
|
123
|
+
) {
|
|
124
|
+
const source = options.changes
|
|
125
|
+
const state: FeedState = {
|
|
126
|
+
options,
|
|
127
|
+
limits,
|
|
128
|
+
conns,
|
|
129
|
+
pokes,
|
|
130
|
+
source,
|
|
131
|
+
live: source
|
|
132
|
+
? { mode: 'off', via: 'none', why: 'not probed yet' }
|
|
133
|
+
: { mode: 'app', via: 'mutations' },
|
|
134
|
+
refused: undefined,
|
|
135
|
+
feed: undefined,
|
|
136
|
+
probed: Promise.resolve(),
|
|
137
|
+
waking: undefined,
|
|
138
|
+
resting: undefined,
|
|
139
|
+
beats: undefined,
|
|
140
|
+
beat: 0,
|
|
141
|
+
}
|
|
142
|
+
state.feed =
|
|
143
|
+
source &&
|
|
144
|
+
createFeed(source, {
|
|
145
|
+
emit: (p) => pokes().got(JSON.stringify(p)),
|
|
146
|
+
bump: (tags) => void (options.store ?? defaultStore).bump(tags),
|
|
147
|
+
status: (down) => status(state, down),
|
|
148
|
+
})
|
|
149
|
+
state.probed = source && !proc.process?.env.WEFT_BUILD ? settle(state, source) : Promise.resolve()
|
|
150
|
+
state.probed.catch(() => {})
|
|
151
|
+
return {
|
|
152
|
+
want: () => want(state),
|
|
153
|
+
rest: () => rest(state),
|
|
154
|
+
wire: () => wire(state),
|
|
155
|
+
heartbeat: () => {
|
|
156
|
+
state.beat = Date.now()
|
|
157
|
+
status(state, undefined)
|
|
158
|
+
},
|
|
159
|
+
live: () => state.live,
|
|
160
|
+
refused: () => state.refused,
|
|
161
|
+
probed: state.probed,
|
|
162
|
+
feed: state.feed,
|
|
163
|
+
clear: () => {
|
|
164
|
+
clearTimeout(state.resting)
|
|
165
|
+
clearInterval(state.beats)
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Poke } from '../changes'
|
|
2
|
+
import type { Channel, Conn, Ev } from './types'
|
|
3
|
+
|
|
4
|
+
interface Dependencies {
|
|
5
|
+
channels: Map<string, Channel>
|
|
6
|
+
channel: (name: string) => Channel
|
|
7
|
+
conns: Map<string, Conn>
|
|
8
|
+
watchers: Set<(tags: string[]) => void>
|
|
9
|
+
cursorOf: (n: number) => string
|
|
10
|
+
seq: () => number
|
|
11
|
+
poke: (c: Conn, tags: string[], at: number) => void
|
|
12
|
+
replay: (ch: Channel, after: string) => Ev[] | undefined
|
|
13
|
+
heartbeat: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Listens from the first realtime use on, not from boot: `weft build` loads this with no bus to reach. */
|
|
17
|
+
function pokes(deps: Dependencies): Channel {
|
|
18
|
+
const { channels, channel, conns, cursorOf, seq, poke, watchers, heartbeat } = deps
|
|
19
|
+
const had = channels.get('weft:poke')
|
|
20
|
+
if (had) return had
|
|
21
|
+
const ch = channel('weft:poke')
|
|
22
|
+
ch.listeners.add((ev) => {
|
|
23
|
+
if (!ev) {
|
|
24
|
+
for (const c of conns.values()) c.sink.write('R', JSON.stringify({ c: cursorOf(seq()) }))
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
const m = JSON.parse(ev.raw) as Poke & { o?: string; b?: 1 }
|
|
28
|
+
if (m.b) {
|
|
29
|
+
// an external listener's heartbeat
|
|
30
|
+
return heartbeat()
|
|
31
|
+
}
|
|
32
|
+
for (const c of conns.values()) if (c.k !== m.o) poke(c, picked(c, m), ev.seq)
|
|
33
|
+
for (const w of watchers) w(m.t)
|
|
34
|
+
})
|
|
35
|
+
return ch
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What this connection subscribed to. `k`: a mutation's poke already reaches a subscription with one of these tags. */
|
|
39
|
+
function picked(c: Conn, { t, p, k }: Poke) {
|
|
40
|
+
if (!p && !k) return t.filter((x) => c.tags.has(x))
|
|
41
|
+
const out = new Set<string>()
|
|
42
|
+
for (const s of c.subs.values()) {
|
|
43
|
+
if (k && s.tags.some((x) => k.includes(x))) continue
|
|
44
|
+
for (const x of s.tags) if (t.includes(x) || p?.some((pre) => x.startsWith(pre))) out.add(x)
|
|
45
|
+
}
|
|
46
|
+
return [...out]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function replayPokes(deps: Dependencies, c: Conn, after: string) {
|
|
50
|
+
const { replay, poke, cursorOf, seq } = deps
|
|
51
|
+
const list = replay(pokes(deps), after)
|
|
52
|
+
if (!list) return c.sink.write('R', JSON.stringify({ c: cursorOf(seq()) }))
|
|
53
|
+
for (const ev of list) {
|
|
54
|
+
const m = JSON.parse(ev.raw) as Poke & { o?: string }
|
|
55
|
+
if (m.o !== c.k) poke(c, picked(c, m), ev.seq)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createPresence(deps: Dependencies) {
|
|
60
|
+
return {
|
|
61
|
+
pokes: () => pokes(deps),
|
|
62
|
+
replayPokes: (c: Conn, after: string) => replayPokes(deps, c, after),
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/hub/route.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { StartError } from '../data'
|
|
2
|
+
import { sessionOf } from '../mux-session'
|
|
3
|
+
import type { Scope } from '../exec'
|
|
4
|
+
import type { Body, Conn, Hub, Sink } from '../hub'
|
|
5
|
+
|
|
6
|
+
// ---- the HTTP route -------------------------------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const enc = new TextEncoder()
|
|
9
|
+
/** Whether a request body has the shape of `Body`. */
|
|
10
|
+
export const validBody = (input: unknown): input is Body => {
|
|
11
|
+
const b = input as Partial<Record<keyof Body, unknown>> | null | undefined
|
|
12
|
+
return (
|
|
13
|
+
!!b &&
|
|
14
|
+
typeof b === 'object' &&
|
|
15
|
+
!Array.isArray(b) &&
|
|
16
|
+
(b.k === undefined || typeof b.k === 'string') &&
|
|
17
|
+
(b.c === undefined || typeof b.c === 'string') &&
|
|
18
|
+
(b.d === undefined ||
|
|
19
|
+
(Array.isArray(b.d) && b.d.every((n: unknown) => typeof n === 'number'))) &&
|
|
20
|
+
(b.s === undefined ||
|
|
21
|
+
(Array.isArray(b.s) &&
|
|
22
|
+
b.s.every(
|
|
23
|
+
(e: unknown) =>
|
|
24
|
+
Array.isArray(e) &&
|
|
25
|
+
typeof e[0] === 'number' &&
|
|
26
|
+
(e[1] === 'q' || e[1] === 's') &&
|
|
27
|
+
typeof e[2] === 'string',
|
|
28
|
+
)))
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `/_weft/rt` (research 30 §4): opens a connection, or changes one. Same proof as a mutation, plus `origins`. */
|
|
33
|
+
export async function handleRoute(
|
|
34
|
+
request: Request,
|
|
35
|
+
scope: Scope,
|
|
36
|
+
origins: string[],
|
|
37
|
+
hub: () => Hub,
|
|
38
|
+
): Promise<Response> {
|
|
39
|
+
const refuse = (code: string, message: string, status: number) => {
|
|
40
|
+
scope.options.onDiagnostic?.({ code, severity: 'error', message })
|
|
41
|
+
return Response.json(
|
|
42
|
+
{ e: { code, message } },
|
|
43
|
+
{ status, headers: { 'cache-control': 'no-store' } },
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
if (request.method !== 'POST') return refuse('Q010', 'Q010: `/_weft/rt` needs POST', 405)
|
|
47
|
+
const url = new URL(request.url)
|
|
48
|
+
const origin = request.headers.get('origin')
|
|
49
|
+
const site = request.headers.get('sec-fetch-site')
|
|
50
|
+
const allowed = origin && origins.includes(origin)
|
|
51
|
+
if (
|
|
52
|
+
!allowed &&
|
|
53
|
+
((origin && origin !== url.origin) || (site && site !== 'same-origin' && site !== 'none'))
|
|
54
|
+
)
|
|
55
|
+
return refuse(
|
|
56
|
+
'R010',
|
|
57
|
+
`R010: origin \`${origin ?? site}\` may not open a realtime connection: list it in \`realtime({ origins })\``,
|
|
58
|
+
403,
|
|
59
|
+
)
|
|
60
|
+
if (
|
|
61
|
+
request.headers.get('x-weft') !== '1' ||
|
|
62
|
+
!request.headers.get('content-type')?.startsWith('application/json')
|
|
63
|
+
)
|
|
64
|
+
return refuse(
|
|
65
|
+
'Q010',
|
|
66
|
+
'Q010: `/_weft/rt` without the CSRF proof (POST + JSON + `x-weft: 1`)',
|
|
67
|
+
403,
|
|
68
|
+
)
|
|
69
|
+
const h = hub()
|
|
70
|
+
const raw = await request.text()
|
|
71
|
+
if (raw.length > h.limits.body)
|
|
72
|
+
return refuse(
|
|
73
|
+
'R008',
|
|
74
|
+
`R008: a realtime request of ${raw.length} bytes; the limit is ${h.limits.body}`,
|
|
75
|
+
413,
|
|
76
|
+
)
|
|
77
|
+
let body: Body
|
|
78
|
+
try {
|
|
79
|
+
body = JSON.parse(raw)
|
|
80
|
+
} catch {
|
|
81
|
+
return refuse('Q002', "Q002: `/_weft/rt` got a body that isn't JSON", 400)
|
|
82
|
+
}
|
|
83
|
+
// A tab that didn't dial the shared socket asks who it is now (link.ts).
|
|
84
|
+
if ((body as { w?: 1 })?.w === 1)
|
|
85
|
+
return Response.json(
|
|
86
|
+
{ u: await sessionOf(await scope.ctx()) },
|
|
87
|
+
{ headers: { 'cache-control': 'no-store' } },
|
|
88
|
+
)
|
|
89
|
+
if (!validBody(body))
|
|
90
|
+
return refuse('Q002', 'Q002: `/_weft/rt` got a body of the wrong shape', 400)
|
|
91
|
+
if (body.k) {
|
|
92
|
+
const c = h.find(body.k)
|
|
93
|
+
const user = c && ((await scope.ctx()) as { user?: { id?: string } }).user?.id
|
|
94
|
+
if (!c || c.user !== user)
|
|
95
|
+
return refuse('Q009', 'Q009: no such realtime connection (reconnect with the whole set)', 404)
|
|
96
|
+
await h.change(c, request, scope.options, body)
|
|
97
|
+
return new Response(null, { status: 204 })
|
|
98
|
+
}
|
|
99
|
+
return openStream(request, scope, body, h, refuse)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function openStream(
|
|
103
|
+
request: Request,
|
|
104
|
+
scope: Scope,
|
|
105
|
+
body: Body,
|
|
106
|
+
h: Hub,
|
|
107
|
+
refuse: (code: string, message: string, status: number) => Response,
|
|
108
|
+
): Promise<Response> {
|
|
109
|
+
let conn: Conn | undefined
|
|
110
|
+
let queued = 0
|
|
111
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>
|
|
112
|
+
const push = (text: string) => {
|
|
113
|
+
const bytes = enc.encode(text)
|
|
114
|
+
queued += bytes.length
|
|
115
|
+
controller.enqueue(bytes)
|
|
116
|
+
}
|
|
117
|
+
const sink: Sink = {
|
|
118
|
+
write: (event, data) => push(`event: ${event}\ndata: ${data}\n\n`),
|
|
119
|
+
ping: () => push(': ping\n\n'),
|
|
120
|
+
backlog: () => queued,
|
|
121
|
+
close: () => {
|
|
122
|
+
try {
|
|
123
|
+
controller.close()
|
|
124
|
+
} catch {}
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
const out = new ReadableStream<Uint8Array>(
|
|
128
|
+
{
|
|
129
|
+
start: (c) => void (controller = c),
|
|
130
|
+
// The reader took what was queued: the strategy's hwm of 0 means every pull is an empty queue.
|
|
131
|
+
pull() {
|
|
132
|
+
queued = 0
|
|
133
|
+
if (conn) h.drain(conn)
|
|
134
|
+
},
|
|
135
|
+
cancel() {
|
|
136
|
+
if (conn) h.close(conn)
|
|
137
|
+
else closed = true
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{ highWaterMark: 0 },
|
|
141
|
+
)
|
|
142
|
+
let closed = false
|
|
143
|
+
try {
|
|
144
|
+
conn = await h.connect(request, scope, body, sink)
|
|
145
|
+
} catch (e) {
|
|
146
|
+
if (e instanceof StartError) return refuse(e.code, e.message, (e.extra.status as number) ?? 500)
|
|
147
|
+
throw e
|
|
148
|
+
}
|
|
149
|
+
if (closed) h.close(conn)
|
|
150
|
+
return new Response(out, {
|
|
151
|
+
headers: {
|
|
152
|
+
'content-type': 'text/event-stream',
|
|
153
|
+
'cache-control': 'no-store',
|
|
154
|
+
'x-accel-buffering': 'no',
|
|
155
|
+
connection: 'keep-alive',
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { enter, fns, StartError, type Context, type Fn } from '../data'
|
|
2
|
+
import {
|
|
3
|
+
createScope,
|
|
4
|
+
guard,
|
|
5
|
+
liveQueries,
|
|
6
|
+
missedFeed,
|
|
7
|
+
tagsOf,
|
|
8
|
+
validate,
|
|
9
|
+
type ScopeOptions,
|
|
10
|
+
} from '../exec'
|
|
11
|
+
import type { Source } from '../changes'
|
|
12
|
+
import type { Body, Conn, Limits, Sub, SubEntry } from './types'
|
|
13
|
+
import { RESET, Tracked } from './tracked'
|
|
14
|
+
|
|
15
|
+
interface Dependencies {
|
|
16
|
+
limits: Limits
|
|
17
|
+
source: Source | undefined
|
|
18
|
+
deliver: (c: Conn, s: Sub, raw: string) => boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A subscription refused before it existed. */
|
|
22
|
+
const refuse = (c: Conn, n: number, e: StartError) =>
|
|
23
|
+
c.sink.write('e', JSON.stringify([n, { code: e.code, message: e.message }]))
|
|
24
|
+
|
|
25
|
+
function fail(c: Conn, s: Sub, e: unknown) {
|
|
26
|
+
remove(c, s)
|
|
27
|
+
if (e instanceof StartError)
|
|
28
|
+
return c.sink.write(
|
|
29
|
+
'e',
|
|
30
|
+
JSON.stringify([
|
|
31
|
+
s.n,
|
|
32
|
+
{
|
|
33
|
+
code: e.code,
|
|
34
|
+
message: e.message,
|
|
35
|
+
...(e.extra.issues ? { issues: e.extra.issues } : {}),
|
|
36
|
+
},
|
|
37
|
+
]),
|
|
38
|
+
)
|
|
39
|
+
s.options.onDiagnostic?.({
|
|
40
|
+
code: 'E_RT',
|
|
41
|
+
severity: 'error',
|
|
42
|
+
message: `${s.fn.$id}: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`,
|
|
43
|
+
})
|
|
44
|
+
c.sink.write(
|
|
45
|
+
'e',
|
|
46
|
+
JSON.stringify([
|
|
47
|
+
s.n,
|
|
48
|
+
{ message: s.options.dev && e instanceof Error ? e.message : 'Subscription failed.' },
|
|
49
|
+
]),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function remove(c: Conn, s: Sub) {
|
|
54
|
+
s.ac.abort()
|
|
55
|
+
c.subs.delete(s.n)
|
|
56
|
+
c.held.delete(s)
|
|
57
|
+
for (const x of s.q) c.bytes -= x.size
|
|
58
|
+
for (const t of s.tags) {
|
|
59
|
+
const left = c.tags.get(t)! - 1
|
|
60
|
+
left ? c.tags.set(t, left) : c.tags.delete(t)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function add(
|
|
65
|
+
deps: Dependencies,
|
|
66
|
+
c: Conn,
|
|
67
|
+
entry: SubEntry,
|
|
68
|
+
request: Request,
|
|
69
|
+
scoped: ScopeOptions,
|
|
70
|
+
) {
|
|
71
|
+
const { limits, source } = deps
|
|
72
|
+
const [n, kind, id, input, cursor, buffer] = entry
|
|
73
|
+
const fn = fns.get(id)
|
|
74
|
+
if (c.subs.size >= limits.subscriptions)
|
|
75
|
+
return refuse(
|
|
76
|
+
c,
|
|
77
|
+
n,
|
|
78
|
+
new StartError(
|
|
79
|
+
'R009',
|
|
80
|
+
`this connection already has ${limits.subscriptions} subscriptions`,
|
|
81
|
+
'read fewer keys, or raise `limits.subscriptions`',
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
if (!fn || fn.kind !== (kind === 'q' ? 'query' : 'stream'))
|
|
85
|
+
return refuse(
|
|
86
|
+
c,
|
|
87
|
+
n,
|
|
88
|
+
new StartError(
|
|
89
|
+
'Q009',
|
|
90
|
+
`no ${kind === 'q' ? 'query' : 'stream'} \`${id}\` (the page is older than the server): reload`,
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
if (c.subs.has(n)) return
|
|
94
|
+
const s: Sub = {
|
|
95
|
+
n,
|
|
96
|
+
kind,
|
|
97
|
+
fn,
|
|
98
|
+
input,
|
|
99
|
+
tags: [],
|
|
100
|
+
buffer: buffer ?? 0,
|
|
101
|
+
q: [],
|
|
102
|
+
ac: new AbortController(),
|
|
103
|
+
request,
|
|
104
|
+
options: scoped,
|
|
105
|
+
}
|
|
106
|
+
c.subs.set(n, s) // before any await: a `d` that follows finds it
|
|
107
|
+
try {
|
|
108
|
+
const valid = await validate(fn, input)
|
|
109
|
+
if (s.ac.signal.aborted) return
|
|
110
|
+
if (kind === 's') return void stream(deps, c, s, valid, cursor)
|
|
111
|
+
guard(fn, fn.def.auth ? await createScope(request, scoped).ctx() : ({} as Context))
|
|
112
|
+
if (s.ac.signal.aborted) return
|
|
113
|
+
s.tags = await tagsOf(fn, valid, createScope(request, scoped))
|
|
114
|
+
for (const t of s.tags) c.tags.set(t, (c.tags.get(t) ?? 0) + 1)
|
|
115
|
+
if (scoped.dev) {
|
|
116
|
+
liveQueries.set(fn.$id, s.tags)
|
|
117
|
+
if (source) missedFeed(fn, s.tags, source.tables, scoped.onDiagnostic)
|
|
118
|
+
if (!s.tags.length)
|
|
119
|
+
scoped.onDiagnostic?.({
|
|
120
|
+
code: 'W053',
|
|
121
|
+
severity: 'error',
|
|
122
|
+
message: `\`${fn.$id}\` is read with \`.live()\` but declares no \`tags\`: no mutation can ever poke it. Add \`tags\` to the query`,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
c.sink.write('a', JSON.stringify([n, s.tags]))
|
|
126
|
+
} catch (e) {
|
|
127
|
+
fail(c, s, e)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** One stream's `run`: resumed with its cursor, restarted without one on a reset. */
|
|
132
|
+
async function stream(deps: Dependencies, c: Conn, s: Sub, input: unknown, cursor?: string) {
|
|
133
|
+
const { limits, deliver } = deps
|
|
134
|
+
const { signal } = s.ac
|
|
135
|
+
for (;;) {
|
|
136
|
+
try {
|
|
137
|
+
const scope = createScope(s.request, s.options)
|
|
138
|
+
const frame = s.options.dev
|
|
139
|
+
? Object.create(scope, { touched: { value: { read: new Set(), write: new Set() } } })
|
|
140
|
+
: scope
|
|
141
|
+
await enter(frame, async () => {
|
|
142
|
+
const ctx = await scope.ctx()
|
|
143
|
+
guard(s.fn, ctx)
|
|
144
|
+
const replaced = s.options.handlers?.get(s.fn.$id) as
|
|
145
|
+
| ((
|
|
146
|
+
input: unknown,
|
|
147
|
+
ctx: Context,
|
|
148
|
+
o: { signal: AbortSignal; lastEventId: unknown },
|
|
149
|
+
) => unknown)
|
|
150
|
+
| undefined
|
|
151
|
+
const out = replaced
|
|
152
|
+
? replaced(input, ctx, { signal, lastEventId: cursor })
|
|
153
|
+
: s.fn.def.run!({ input, ctx, signal, lastEventId: cursor })
|
|
154
|
+
for await (const y of out as AsyncIterable<unknown>) {
|
|
155
|
+
if (signal.aborted) return
|
|
156
|
+
const [id, msg] = y instanceof Tracked ? [y.id, y.value] : [null, y]
|
|
157
|
+
if (s.options.dev) await checked(s.fn, msg)
|
|
158
|
+
const raw = JSON.stringify([s.n, id, msg])
|
|
159
|
+
if (raw.length > limits.message)
|
|
160
|
+
throw new StartError(
|
|
161
|
+
'R008',
|
|
162
|
+
`\`${s.fn.$id}\` yielded ${raw.length} bytes; a message may be ${limits.message}`,
|
|
163
|
+
'send less, or an id the client fetches',
|
|
164
|
+
)
|
|
165
|
+
if (id !== null) cursor = id
|
|
166
|
+
if (!deliver(c, s, raw)) throw RESET
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
if (signal.aborted) return
|
|
170
|
+
remove(c, s)
|
|
171
|
+
c.sink.write('f', JSON.stringify([s.n]))
|
|
172
|
+
return
|
|
173
|
+
} catch (e) {
|
|
174
|
+
if (signal.aborted) return
|
|
175
|
+
if (e !== RESET) return fail(c, s, e)
|
|
176
|
+
cursor = undefined
|
|
177
|
+
c.sink.write('r', JSON.stringify([s.n]))
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function change(
|
|
183
|
+
deps: Dependencies,
|
|
184
|
+
c: Conn,
|
|
185
|
+
request: Request,
|
|
186
|
+
opts: ScopeOptions,
|
|
187
|
+
body: Body,
|
|
188
|
+
) {
|
|
189
|
+
for (const n of body.d ?? []) {
|
|
190
|
+
const s = c.subs.get(n)
|
|
191
|
+
if (s) remove(c, s)
|
|
192
|
+
}
|
|
193
|
+
await Promise.all((body.s ?? []).map((e) => add(deps, c, e, request, opts)))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function checked(fn: Fn, msg: unknown) {
|
|
197
|
+
const r = await fn.def.message?.['~standard'].validate(msg)
|
|
198
|
+
if (r?.issues)
|
|
199
|
+
throw new StartError(
|
|
200
|
+
'C002',
|
|
201
|
+
`\`${fn.$id}\` yielded a message its \`message\` schema rejects: ${r.issues.map((i) => `${i.path?.map((p) => (typeof p === 'object' ? p.key : p)).join('.') ?? ''}: ${i.message}`).join('; ')}`,
|
|
202
|
+
'fix `run`, or the schema',
|
|
203
|
+
{ status: 500 },
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function createSubscriptions(deps: Dependencies) {
|
|
208
|
+
return {
|
|
209
|
+
fail,
|
|
210
|
+
change: change.bind(null, deps),
|
|
211
|
+
}
|
|
212
|
+
}
|
package/src/hub/topic.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { currentScope, StartError, type StandardSchemaV1 } from '../data'
|
|
2
|
+
import type { Ev, Hub } from '../hub'
|
|
3
|
+
import { RESET, tracked, type Tracked } from './tracked'
|
|
4
|
+
|
|
5
|
+
// ---- topics ---------------------------------------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
/** What `topic()` returns: publish on the server, follow with `.live`. */
|
|
8
|
+
export interface Topic<K, M> {
|
|
9
|
+
/** Fan a message out to every subscriber, on every instance. */
|
|
10
|
+
publish(key: K, message: M): Promise<void>
|
|
11
|
+
/**
|
|
12
|
+
* The messages of `keys`. Listens from this call, so a read that follows can't miss one (R006's gap): call it before
|
|
13
|
+
* reading a backlog. With `after`, replays what the ring still has; outside the window the stream resets.
|
|
14
|
+
*/
|
|
15
|
+
subscribe(
|
|
16
|
+
keys: K | K[],
|
|
17
|
+
options?: { signal?: AbortSignal | undefined; after?: string | undefined },
|
|
18
|
+
): AsyncGenerator<Tracked<M>, void, void>
|
|
19
|
+
readonly name: string
|
|
20
|
+
readonly message?: StandardSchemaV1<unknown, M>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Out<S> = S extends StandardSchemaV1<unknown, infer O> ? O : never
|
|
24
|
+
|
|
25
|
+
/** A typed server channel: mutations, jobs and webhooks publish, streams read. */
|
|
26
|
+
export function makeTopic<
|
|
27
|
+
K extends StandardSchemaV1<unknown, string | number>,
|
|
28
|
+
M extends StandardSchemaV1,
|
|
29
|
+
>(name: string, def: { key?: K; message: M }, hub: () => Hub): Topic<Out<K>, Out<M>> {
|
|
30
|
+
const path = (key: unknown) => `weft:t:${name}:${String(key)}`
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
message: def.message as StandardSchemaV1<unknown, Out<M>>,
|
|
34
|
+
async publish(key, message) {
|
|
35
|
+
const raw = JSON.stringify(message)
|
|
36
|
+
const h = hub()
|
|
37
|
+
if (raw.length > h.limits.message)
|
|
38
|
+
throw new StartError(
|
|
39
|
+
'R008',
|
|
40
|
+
`a \`${name}\` message is ${raw.length} bytes; a message may be ${h.limits.message}`,
|
|
41
|
+
'publish less, or an id subscribers fetch',
|
|
42
|
+
)
|
|
43
|
+
await h.bus.publish(path(key), raw)
|
|
44
|
+
},
|
|
45
|
+
subscribe(keys, { signal, after } = {}) {
|
|
46
|
+
const h = hub()
|
|
47
|
+
if (currentScope()?.touched?.read.size)
|
|
48
|
+
currentScope()!.options.onDiagnostic?.({
|
|
49
|
+
code: 'R006',
|
|
50
|
+
severity: 'warning',
|
|
51
|
+
message: `a stream read ${[...currentScope()!.touched!.read].join(', ')} before \`${name}.subscribe()\`: anything published in between is lost. Call \`subscribe\` first, or pass \`after\``,
|
|
52
|
+
})
|
|
53
|
+
const chans = (Array.isArray(keys) ? keys : [keys]).map((k) => h.channel(path(k)))
|
|
54
|
+
const q: Ev[] = []
|
|
55
|
+
let wake: (() => void) | undefined
|
|
56
|
+
let lost = false
|
|
57
|
+
const on = (ev: Ev | null) => {
|
|
58
|
+
if (ev) q.push(ev)
|
|
59
|
+
else lost = true
|
|
60
|
+
if (q.length > h.limits.ring) lost = true
|
|
61
|
+
wake?.()
|
|
62
|
+
}
|
|
63
|
+
for (const ch of chans) ch.listeners.add(on) // before anything is read: that's the rule
|
|
64
|
+
const stop = () => {
|
|
65
|
+
for (const ch of chans) h.unlisten(ch, on)
|
|
66
|
+
}
|
|
67
|
+
const abort = () => wake?.()
|
|
68
|
+
signal?.addEventListener('abort', abort)
|
|
69
|
+
return (async function* () {
|
|
70
|
+
try {
|
|
71
|
+
await Promise.all(chans.map((c) => c.ready))
|
|
72
|
+
if (after) {
|
|
73
|
+
const seen = new Set(q.map((e) => e.seq))
|
|
74
|
+
for (const ch of chans) {
|
|
75
|
+
const back = h.replay(ch, after)
|
|
76
|
+
if (!back) throw RESET
|
|
77
|
+
for (const ev of back) if (!seen.has(ev.seq)) q.push(ev)
|
|
78
|
+
}
|
|
79
|
+
q.sort((a, b) => a.seq - b.seq)
|
|
80
|
+
}
|
|
81
|
+
for (;;) {
|
|
82
|
+
if (signal?.aborted) return
|
|
83
|
+
if (lost) throw RESET
|
|
84
|
+
const ev = q.shift()
|
|
85
|
+
if (ev) yield tracked(h.cursorOf(ev.seq), JSON.parse(ev.raw))
|
|
86
|
+
else
|
|
87
|
+
await new Promise<void>((ok) => {
|
|
88
|
+
wake = ok
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
signal?.removeEventListener('abort', abort)
|
|
93
|
+
stop()
|
|
94
|
+
}
|
|
95
|
+
})()
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|