@weft-js/start 0.1.0 → 0.1.2

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