@weft-js/start 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/README.md +36 -3
  2. package/crate/Cargo.lock +1347 -0
  3. package/crate/Cargo.toml +72 -0
  4. package/crate/rust-toolchain.toml +3 -0
  5. package/crate/src/build/emit.rs +103 -0
  6. package/crate/src/build.rs +367 -0
  7. package/crate/src/ctx.rs +186 -0
  8. package/crate/src/envelope.rs +163 -0
  9. package/crate/src/functions.rs +173 -0
  10. package/crate/src/lib.rs +118 -0
  11. package/crate/src/native.rs +212 -0
  12. package/crate/src/otel.rs +62 -0
  13. package/crate/src/server.rs +203 -0
  14. package/package.json +46 -37
  15. package/src/app.mjs +51 -10
  16. package/src/asset-receipt.d.mts +13 -0
  17. package/src/asset-receipt.mjs +61 -0
  18. package/src/auth/better-auth.ts +215 -17
  19. package/src/auth/drizzle.ts +1 -1
  20. package/src/auth.ts +21 -6
  21. package/src/boot.ts +19 -8
  22. package/src/bus/postgres.ts +32 -19
  23. package/src/bus.ts +7 -4
  24. package/src/{capture.mjs → capture.ts} +34 -1
  25. package/src/changes-sql.d.mts +8 -2
  26. package/src/changes-sql.mjs +26 -10
  27. package/src/changes.ts +165 -66
  28. package/src/channel/connections.ts +216 -0
  29. package/src/channel/socket.ts +396 -0
  30. package/src/channel-fake.ts +19 -4
  31. package/src/channel.ts +124 -490
  32. package/src/client.tsx +9 -2
  33. package/src/contract/describe.mjs +231 -0
  34. package/src/contract.mjs +146 -155
  35. package/src/data/cache.ts +354 -0
  36. package/src/data/definitions.ts +94 -0
  37. package/src/data/draft.ts +104 -0
  38. package/src/data/foundation.ts +95 -0
  39. package/src/data/live-api.ts +22 -0
  40. package/src/data/mutation.ts +138 -0
  41. package/src/data/primitives.ts +135 -0
  42. package/src/data/query.ts +106 -0
  43. package/src/data/transport.ts +121 -0
  44. package/src/data/types.ts +303 -0
  45. package/src/data.ts +69 -894
  46. package/src/db/bun-sqlite.d.ts +4 -1
  47. package/src/db/changes-mysql.ts +61 -21
  48. package/src/db/changes-postgres.ts +197 -118
  49. package/src/db/changes-sqlite.ts +131 -64
  50. package/src/db/core.ts +38 -13
  51. package/src/db/index.ts +20 -4
  52. package/src/db/mysql.ts +22 -6
  53. package/src/db/postgres.ts +49 -13
  54. package/src/db/sqlite.ts +53 -27
  55. package/src/derive.d.mts +4 -1
  56. package/src/derive.mjs +136 -39
  57. package/src/dev-runtime.mjs +8 -4
  58. package/src/exec/errors.ts +57 -0
  59. package/src/exec/landing.ts +66 -0
  60. package/src/exec/scope.ts +174 -0
  61. package/src/exec/touch.ts +80 -0
  62. package/src/exec/validation.ts +176 -0
  63. package/src/exec.ts +191 -341
  64. package/src/extension.ts +8 -2
  65. package/src/first-paint.ts +26 -9
  66. package/src/form/value.ts +15 -0
  67. package/src/form.tsx +132 -43
  68. package/src/hub/channels.ts +102 -0
  69. package/src/hub/connections.ts +147 -0
  70. package/src/hub/delivery.ts +62 -0
  71. package/src/hub/feed.ts +168 -0
  72. package/src/hub/presence.ts +64 -0
  73. package/src/hub/route.ts +158 -0
  74. package/src/hub/subscriptions.ts +212 -0
  75. package/src/hub/topic.ts +98 -0
  76. package/src/hub/tracked.ts +12 -0
  77. package/src/hub/types.ts +190 -0
  78. package/src/hub/view.ts +147 -0
  79. package/src/hub.ts +132 -758
  80. package/src/index.ts +84 -10
  81. package/src/inline.mjs +356 -0
  82. package/src/inspect/compare.mjs +162 -0
  83. package/src/inspect/routes.mjs +154 -0
  84. package/src/inspect/samples.mjs +59 -0
  85. package/src/inspect/url.mjs +2 -0
  86. package/src/inspect.mjs +146 -187
  87. package/src/link/election.ts +178 -0
  88. package/src/link/lines.ts +27 -0
  89. package/src/link.ts +97 -168
  90. package/src/list-drizzle.ts +104 -18
  91. package/src/list.ts +127 -36
  92. package/src/listen.ts +32 -6
  93. package/src/live/api.ts +18 -0
  94. package/src/live/backoff.ts +6 -0
  95. package/src/live/frames.ts +42 -0
  96. package/src/live/read.ts +77 -0
  97. package/src/live/sub-types.ts +24 -0
  98. package/src/live/transport.ts +86 -0
  99. package/src/live/types.ts +52 -0
  100. package/src/live-fake.ts +20 -5
  101. package/src/live-target.mjs +113 -35
  102. package/src/live.ts +116 -225
  103. package/src/media.ts +8 -1
  104. package/src/migrations.mjs +14 -6
  105. package/src/mux.ts +161 -64
  106. package/src/og/DefaultCard.tsx +40 -9
  107. package/src/og/fonts.mjs +16 -8
  108. package/src/og/images.mjs +43 -22
  109. package/src/og/index.mjs +119 -43
  110. package/src/og/native.mjs +11 -4
  111. package/src/range.mjs +15 -3
  112. package/src/router.ts +56 -6
  113. package/src/routes.mjs +73 -22
  114. package/src/run/admission.ts +80 -0
  115. package/src/run/boot.tsx +115 -0
  116. package/src/run/cache-presentation.ts +67 -0
  117. package/src/run/cache.ts +254 -0
  118. package/src/run/helpers.ts +197 -0
  119. package/src/run/listeners.ts +155 -0
  120. package/src/run/navigation.ts +382 -0
  121. package/src/run/page-skeleton.tsx +35 -0
  122. package/src/run/prefetch.ts +128 -0
  123. package/src/run/retain-view.ts +18 -0
  124. package/src/run/shape-preparation.ts +97 -0
  125. package/src/run/shape-styles.ts +72 -0
  126. package/src/run/source-context/pin.ts +124 -0
  127. package/src/run/source-context.ts +311 -0
  128. package/src/run.tsx +50 -174
  129. package/src/rust/common.ts +163 -0
  130. package/src/rust/native.ts +282 -0
  131. package/src/rust.ts +103 -229
  132. package/src/server/card.tsx +65 -0
  133. package/src/server/compression.ts +115 -0
  134. package/src/server/core.ts +70 -0
  135. package/src/server/dispatch.ts +70 -0
  136. package/src/server/form.ts +85 -0
  137. package/src/server/functions.ts +129 -0
  138. package/src/server/handler.ts +39 -0
  139. package/src/server/head.ts +96 -0
  140. package/src/server/media.ts +60 -0
  141. package/src/server/navigation-cache.ts +80 -0
  142. package/src/server/navigation.tsx +85 -0
  143. package/src/server/page-cold.ts +95 -0
  144. package/src/server/page-stream.tsx +181 -0
  145. package/src/server/page-types.ts +51 -0
  146. package/src/server/page-warm.ts +130 -0
  147. package/src/server/page.ts +69 -0
  148. package/src/server/payload.ts +89 -0
  149. package/src/server/prerender.ts +60 -0
  150. package/src/server/preview.tsx +67 -0
  151. package/src/server/render.ts +167 -0
  152. package/src/server/request.ts +98 -0
  153. package/src/server/resolver.ts +228 -0
  154. package/src/server/routes.ts +150 -0
  155. package/src/server/services.ts +69 -0
  156. package/src/server/text.ts +29 -0
  157. package/src/server/types.ts +160 -0
  158. package/src/server.tsx +82 -964
  159. package/src/shape.mjs +30 -6
  160. package/src/share/display.ts +50 -0
  161. package/src/share/network.ts +119 -0
  162. package/src/share/receive.ts +70 -0
  163. package/src/share/state.ts +30 -0
  164. package/src/share/tab-queue.ts +28 -0
  165. package/src/share-server.ts +128 -43
  166. package/src/share.ts +103 -209
  167. package/src/sheets.mjs +30 -18
  168. package/src/sidecar/build.mjs +170 -0
  169. package/src/sidecar/cargo.mjs +164 -0
  170. package/src/sidecar/common.mjs +17 -0
  171. package/src/sidecar/dev-build.mjs +91 -0
  172. package/src/sidecar/dev-watch.mjs +88 -0
  173. package/src/sidecar/manifest.mjs +19 -0
  174. package/src/sidecar/process.mjs +58 -0
  175. package/src/sidecar/refresh.mjs +19 -0
  176. package/src/sidecar.mjs +75 -416
  177. package/src/socket/check.ts +50 -0
  178. package/src/socket/deliver.ts +16 -0
  179. package/src/socket/publish.ts +56 -0
  180. package/src/socket/rooms.ts +41 -0
  181. package/src/socket/route.ts +79 -0
  182. package/src/socket/shared.ts +26 -0
  183. package/src/socket/types.ts +48 -0
  184. package/src/socket.ts +205 -194
  185. package/src/stream.ts +10 -1
  186. package/src/verify/app.ts +39 -0
  187. package/src/verify/cleanup.ts +27 -0
  188. package/src/verify/database.ts +11 -0
  189. package/src/verify/live.ts +63 -0
  190. package/src/verify/notify.ts +35 -0
  191. package/src/verify/skip.ts +19 -0
  192. package/src/verify/support.ts +149 -0
  193. package/src/verify/timing.ts +15 -0
  194. package/src/verify/trigger.ts +14 -0
  195. package/src/verify.ts +77 -176
  196. package/src/view.tsx +96 -11
  197. package/src/vite/asset-bytes.mjs +95 -0
  198. package/src/vite/assets.mjs +117 -0
  199. package/src/vite/config-hooks.mjs +166 -0
  200. package/src/vite/dev-hooks.mjs +103 -0
  201. package/src/vite/dev.mjs +102 -0
  202. package/src/vite/entries.mjs +145 -0
  203. package/src/vite/module-hooks.mjs +288 -0
  204. package/src/vite/route-modules.mjs +289 -0
  205. package/src/vite/server-functions.mjs +222 -0
  206. package/src/vite/shape-entry-css.mjs +187 -0
  207. package/src/vite/shape-override.mjs +226 -0
  208. package/src/vite/shared.mjs +51 -0
  209. package/src/vite/sheets.mjs +92 -0
  210. package/src/vite/state.mjs +89 -0
  211. package/src/vite/syntax.mjs +139 -0
  212. package/src/vite.mjs +152 -853
  213. package/src/capture.d.mts +0 -1
package/src/hub.ts CHANGED
@@ -8,84 +8,49 @@
8
8
  // backpressure past the high-water mark a subscription holds its last message, or up to `buffer`; pokes merge by tag.
9
9
  // A buffer that overflows resets that stream; a connection over `queue` bytes is closed, and resumes.
10
10
 
11
- import type { Bus } from './bus'
12
11
  import { memoryBus } from './bus'
13
- import { createFeed, functionHost, type Feed, type Poke, type Probe, type Source } from './changes'
14
- import { currentScope, enter, fns, StartError, type Context, type Fn, type LiveMode, type StandardSchemaV1 } from './data'
15
- import { sessionOf } from './mux-session'
16
- import type { ShareRule } from './share-server'
17
- import { createScope, defaultStore, guard, liveQueries, missedFeed, tagsOf, validate, type Scope, type ScopeOptions, type Store } from './exec'
18
-
19
- // An app's tsc may have no Node types, so `process` is named here.
20
- const proc = globalThis as unknown as { process?: { env: Record<string, string | undefined>; exit(code: number): void } }
21
-
22
- /** What one connection may cost; every number has a default in `LIMITS`. */
23
- export interface Limits {
24
- subscriptions: number
25
- /** Per signed-in user. */
26
- connections: number
27
- /** Bytes of one request (client→server). */
28
- body: number
29
- /** Bytes of one message (server→client). */
30
- message: number
31
- /** Bytes one connection may hold before it's closed. */
32
- queue: number
33
- /** Events a ring keeps. */
34
- ring: number
35
- /** Ms a ring keeps them. */
36
- window: number
37
- /** Ms between `: ping`s. */
38
- heartbeat: number
39
- /** Ms between session re-checks of `auth` subscriptions and channel joins. */
40
- session: number
41
- /** Channel messages a client may send per second, per socket. */
42
- perSecond: number
43
- /** The burst `perSecond` allows. */
44
- burst: number
45
- /** Keys one socket may `share`. They cost no joins: a 200-row list is one. */
46
- shareKeys: number
47
- }
48
-
49
- const LIMITS: Limits = { subscriptions: 100, connections: 10, body: 64 * 1024, message: 1 << 20, queue: 1 << 20, ring: 256, window: 60_000, heartbeat: 20_000, session: 300_000, perSecond: 20, burst: 40, shareKeys: 1000 }
50
- const HIGH = 64 * 1024
51
- const VOLATILE = '{"v":1'
52
-
53
- /** `realtime()`: the options of an app's `src/realtime.server.ts`. */
54
- export interface RealtimeOptions {
55
- /** Default: `memoryBus()`, one instance only (R002). */
56
- bus?: Bus
57
- /** Origins allowed besides the app's own, exact: scheme, host and port. */
58
- origins?: string[]
59
- limits?: Partial<Limits>
60
- /** Seconds the platform lets a response live: streams end 10 s earlier, and the client resumes. */
61
- maxDuration?: number
62
- /** Writes from outside the app: `changes: dbChanges(db, [todos])`, or a source of your own. */
63
- changes?: Source
64
- /**
65
- * Who may share or see a key of `interaction({ share })` with other clients. Left out, nobody: tabs still work. One
66
- * key at a time, `({ ctx, key }) => boolean`, or `{ keys: ({ ctx, keys }) => allowed }` to answer a whole join at once.
67
- */
68
- share?: ShareRule | undefined
69
- }
70
-
71
- /** What the app gets from `configure`, beside `src/realtime.server.ts`. */
72
- export interface HubOptions extends RealtimeOptions {
73
- build?: string | undefined
74
- dev?: boolean | undefined
75
- /** Queries marked `live: { required: true }`: without a real feed the server refuses to run (R015). */
76
- required?: string[] | undefined
77
- store?: Store | undefined
78
- onDiagnostic?: ScopeOptions['onDiagnostic'] | undefined
79
- }
80
-
81
- /** What `.live` reports, and a health route reads. */
82
- export interface LiveState {
83
- mode: LiveMode
84
- via: string
85
- why?: string | undefined
86
- fix?: string | undefined
87
- /** The feed works in general and is out right now. */
88
- down?: string | undefined
12
+ import { StartError, type StandardSchemaV1 } from './data'
13
+ import type { Scope } from './exec'
14
+ import { createChannels } from './hub/channels'
15
+ import { createConnections } from './hub/connections'
16
+ import { createDelivery } from './hub/delivery'
17
+ import { createFeedState } from './hub/feed'
18
+ import { createPresence } from './hub/presence'
19
+ import { handleRoute } from './hub/route'
20
+ import { createSubscriptions } from './hub/subscriptions'
21
+ import { makeTopic, type Topic } from './hub/topic'
22
+ import type { Conn, Hub, HubOptions, Limits, LiveState, RealtimeOptions } from './hub/types'
23
+ import { createHubView } from './hub/view'
24
+
25
+ export { validBody } from './hub/route'
26
+ export { Tracked, tracked } from './hub/tracked'
27
+ export type {
28
+ Limits,
29
+ RealtimeOptions,
30
+ HubOptions,
31
+ LiveState,
32
+ Sink,
33
+ SubEntry,
34
+ Body,
35
+ Ev,
36
+ Channel,
37
+ Conn,
38
+ Hub,
39
+ } from './hub/types'
40
+
41
+ const LIMITS: Limits = {
42
+ subscriptions: 100,
43
+ connections: 10,
44
+ body: 64 * 1024,
45
+ message: 1 << 20,
46
+ queue: 1 << 20,
47
+ ring: 256,
48
+ window: 60_000,
49
+ heartbeat: 20_000,
50
+ session: 300_000,
51
+ perSecond: 20,
52
+ burst: 40,
53
+ shareKeys: 1000,
89
54
  }
90
55
 
91
56
  /** `src/realtime.server.ts`'s default export. */
@@ -96,545 +61,87 @@ export function realtime(options: RealtimeOptions): RealtimeOptions {
96
61
 
97
62
  /** R004. `realtime()` is a wrapper an app can leave out, so the upgrade calls this too. */
98
63
  export function checkOrigins(origins: string[] = []) {
99
- if (origins.some(o => o.includes('*'))) throw new StartError('R004', '`realtime({ origins })` has a wildcard: any site could read this app\'s streams and join its channels with a visitor\'s cookies', 'list the origins, scheme and port included')
100
- }
101
-
102
- /** Where a connection's frames go. `data` is JSON. */
103
- export interface Sink {
104
- write(event: string, data: string): void
105
- ping?(): void
106
- /** Bytes written and not yet taken by the client. */
107
- backlog(): number
108
- close(): void
64
+ if (origins.some((o) => o.includes('*')))
65
+ throw new StartError(
66
+ 'R004',
67
+ "`realtime({ origins })` has a wildcard: any site could read this app's streams and join its channels with a visitor's cookies",
68
+ 'list the origins, scheme and port included',
69
+ )
109
70
  }
110
71
 
111
- /** What the client sends (research 30 §4): `[n, kind, id, input, cursor, buffer]`. */
112
- export type SubEntry = [n: number, kind: 'q' | 's', id: string, input?: unknown, cursor?: string, buffer?: number]
113
- /** The body of `/_weft/rt`: the cursor, subscriptions to add, and subscriptions to drop. */
114
- export interface Body { k?: string; c?: string | undefined; s?: SubEntry[]; d?: number[] }
115
-
116
- /** A message with its cursor: `yield tracked(row.id, row)`, and a resumed `run` gets it as `lastEventId`. */
117
- export class Tracked<M> {
118
- constructor(public id: string, public value: M) {}
119
- }
120
- /** A message with an id, so a stream can resume after it. */
121
- export const tracked = <M>(id: string, value: M) => new Tracked(id, value)
122
-
123
- /** Thrown out of `run` when its cursor can't resume: the stream restarts without one. */
124
- const RESET = new Error('weft.reset')
125
-
126
- /** One message in a channel's ring. */
127
- export interface Ev { seq: number; at: number; raw: string; /** Parsed once, for every socket of a room. */ p?: unknown }
128
- /** One hub channel: its replay ring and who's listening on this instance. */
129
- export interface Channel {
130
- name: string
131
- ring: Ev[]
132
- floor: number
133
- listeners: Set<(ev: Ev | null) => void>
134
- /** Take a message as if the bus had brought it: a change feed reaches this instance's connections only. */
135
- got: (raw: string) => void
136
- ready: Promise<void>
137
- stop?: () => void
138
- idle?: ReturnType<typeof setTimeout> | undefined
139
- }
140
-
141
- interface Sub {
142
- n: number
143
- kind: 'q' | 's'
144
- fn: Fn
145
- input: unknown
146
- tags: string[]
147
- buffer: number
148
- q: { raw: string; size: number }[]
149
- ac: AbortController
150
- request: Request
151
- options: ScopeOptions
152
- }
153
-
154
- /** One client's realtime connection. */
155
- export interface Conn {
156
- k: string
157
- sink: Sink
158
- user?: string | undefined
159
- subs: Map<number, Sub>
160
- tags: Map<string, number>
161
- held: Set<Sub>
162
- pokes?: { t: Set<string>; c: string } | undefined
163
- bytes: number
164
- timers: ReturnType<typeof setTimeout>[]
165
- }
166
-
167
- /** The realtime server of this process: channels, connections and the change feed. */
168
- export interface Hub {
169
- readonly bus: Bus
170
- readonly limits: Limits
171
- /** `realtime({ share })`. */
172
- readonly share?: ShareRule | undefined
173
- poke(tags: string[], origin?: string | null): Promise<void>
174
- cursor(): string | undefined
175
- /** Listening for pokes, or 250 ms: a page waits this long for a cursor, and goes without one. */
176
- ready(): Promise<unknown>
177
- cursorOf(seq: number): string
178
- channel(name: string): Channel
179
- replay(ch: Channel, after: string): Ev[] | undefined
180
- unlisten(ch: Channel, on: (ev: Ev | null) => void): void
181
- /** `line`: one tab on a shared socket (mux.ts), which `limits.connections` counted already. */
182
- connect(request: Request, scope: Scope, body: Body, sink: Sink, line?: boolean): Promise<Conn>
183
- change(c: Conn, request: Request, options: ScopeOptions, body: Body): Promise<void>
184
- drain(c: Conn): void
185
- close(c: Conn): void
186
- find(k: string): Conn | undefined
187
- connections(): Conn[]
188
- /** @weft-js/core/test: a message straight to every reader of a stream. */
189
- push(id: string, msg: unknown): number
190
- /** Server-side counts for tests and `weft browser`. */
191
- stats(): { connections: number; subscriptions: number; channels: number }
192
- /** The live mode this server settled on: `.liveMode` on the client, and health routes. */
193
- live(): LiveState
194
- required: boolean
195
- /** Settled once the source has been probed. */
196
- probed: Promise<unknown>
197
- /** R015: required live queries, and no feed. Requests get this instead of stale data. */
198
- refused?: StartError | undefined
199
- shutdown(): Promise<void>
200
- /** The position a new listener resumes from. */
201
- position(): string
202
- /** Every poked tag, on this instance: socket.ts's `session:<user>` re-check. */
203
- watch(fn: (tags: string[]) => void): void
204
- /** Run at shutdown: socket.ts closes its sockets. */
205
- closing: Set<() => void>
206
- /** `room.publish` checks its messages (C002). */
207
- dev?: boolean | undefined
208
- /** The instance's name in a shared value's stamp. */
209
- epoch: string
210
- /** Seconds a connection may live (`RealtimeOptions.maxDuration`). */
211
- maxDuration?: number | undefined
212
- build?: string | undefined
213
- }
214
-
215
- const unref = <T,>(t: T) => ((t as { unref?(): void } | null | undefined)?.unref?.(), t)
216
-
217
72
  /** A hub on these options; `hub()` is the usual way to reach the one a server made. */
218
73
  export function createHub(options: HubOptions): Hub {
219
74
  const bus = options.bus ?? memoryBus()
220
75
  const limits = { ...LIMITS, ...options.limits }
221
76
  const epoch = crypto.randomUUID().slice(0, 8)
222
- const channels = new Map<string, Channel>()
77
+ const { channels, channel, unlisten, replay, seq } = createChannels(bus, limits, epoch)
223
78
  const conns = new Map<string, Conn>()
224
79
  const watchers = new Set<(tags: string[]) => void>()
225
80
  const closing = new Set<() => void>()
226
- let seq = 0
227
81
  const cursorOf = (n: number) => `${epoch}:${n}`
82
+ const { poke, deliver, drain } = createDelivery(limits, cursorOf, (c, retry) =>
83
+ connections.bye(c, retry),
84
+ )
228
85
  const source = options.changes
229
- let live: LiveState = source ? { mode: 'off', via: 'none', why: 'not probed yet' } : { mode: 'app', via: 'mutations' }
230
- let refused: StartError | undefined
231
- let waking: Promise<unknown> | undefined, resting: ReturnType<typeof setTimeout> | undefined, beats: ReturnType<typeof setInterval> | undefined, beat = 0
232
- const report = (code: string, severity: string, message: string) => options.onDiagnostic?.({ code, severity, message })
233
- const status = (down: string | undefined) => {
234
- if (live.down === down) return
235
- live = { ...live, down }
236
- if (down) report('R016', 'warning', `live: ${down}`)
237
- for (const c of conns.values()) c.sink.write('L', JSON.stringify(wire()))
238
- }
239
- const wire = () => ({ m: live.mode, ...(live.down && { d: 1 }) })
240
- const feed: Feed | undefined = source && createFeed(source, {
241
- emit: p => pokes().got(JSON.stringify(p)),
242
- bump: tags => void (options.store ?? defaultStore).bump(tags),
243
- status,
86
+ const { fail, change } = createSubscriptions({ limits, source, deliver })
87
+ const presence = createPresence({
88
+ channels,
89
+ channel,
90
+ conns,
91
+ watchers,
92
+ cursorOf,
93
+ seq,
94
+ poke,
95
+ replay,
96
+ heartbeat: () => feedState.heartbeat(),
244
97
  })
245
- const probed: Promise<unknown> = source && !proc.process?.env.WEFT_BUILD ? settle(source) : Promise.resolve()
246
- probed.catch(() => {})
247
-
248
- /** Probes rather than assumes (`what works here`), says what it found once, and refuses to serve what was required. */
249
- async function settle(src: Source) {
250
- const host = functionHost()
251
- const how: Probe = src.external
252
- ? { mode: 'feed', via: 'listener' }
253
- : host
254
- ? { mode: 'off', via: 'none', why: `${host} ends a process with its request, so nothing can hold a database connection`, fix: 'run `weft db listen` on an always-on host and set `listener: \'external\'`, or deploy to a server preset' }
255
- : await src.probe().catch(e => ({ mode: 'off' as const, via: 'none', why: e instanceof Error ? e.message : String(e), fix: 'check the database' }))
256
- live = { mode: src.external ? 'external' : how.mode, via: how.via, why: how.why, fix: how.fix }
257
- const line = `live: ${live.mode} (${live.via})${how.why ? `: ${how.why}` : ''}`
258
- if (how.mode === 'feed') console.info(`weft ${line}`)
259
- else {
260
- console.warn(`weft ${line}${how.fix ? `\n fix: ${how.fix}` : ''}`)
261
- report('R016', 'warning', `${line}. \`.live()\` shows \`degraded\``)
262
- }
263
- if (how.mode === 'feed' || !options.required?.length) return how
264
- refused = new StartError('R015', `${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}` : ''}`, how.fix ?? 'see `weft doctor`', { status: 503 })
265
- console.error(`weft ${refused.message}`)
266
- if (!options.dev) proc.process?.exit(1)
267
- return how
268
- }
269
-
270
- /** The feed runs while someone can use it: a server with no clients holds no listener and runs no watcher. */
271
- function want() {
272
- clearTimeout(resting)
273
- resting = undefined
274
- if (!source) return
275
- if (source.external) {
276
- if (beats) return
277
- beat = Date.now()
278
- void pokes()
279
- beats = unref(setInterval(() => { if (Date.now() - beat > limits.window / 2) status('the external listener has gone quiet') }, limits.heartbeat / 2))
280
- return
281
- }
282
- return (waking ??= probed.then(how => feed!.start(how as Probe)).catch(e => status(e instanceof Error ? e.message : String(e))).finally(() => { waking = undefined }))
283
- }
284
-
285
- function rest() {
286
- if (!source || conns.size || resting || !feed?.running) return
287
- resting = unref(setTimeout(() => {
288
- resting = undefined
289
- if (!conns.size) void feed.stop()
290
- }, limits.window))
291
- }
292
-
293
- const trim = (ch: Channel) => {
294
- const old = Date.now() - limits.window
295
- let drop = 0
296
- while (drop < ch.ring.length && (ch.ring[drop]!.at < old || ch.ring.length - drop > limits.ring)) drop++
297
- if (!drop) return
298
- ch.floor = ch.ring[drop - 1]!.seq
299
- ch.ring.splice(0, drop)
300
- }
301
-
302
- function channel(name: string): Channel {
303
- const ch = channels.get(name)
304
- if (ch) {
305
- clearTimeout(ch.idle)
306
- ch.idle = undefined
307
- return ch
308
- }
309
- const made: Channel = { name, ring: [], floor: seq, listeners: new Set(), got: undefined as never, ready: undefined as never }
310
- channels.set(name, made)
311
- made.got = raw => {
312
- const ev = { seq: ++seq, at: Date.now(), raw }
313
- // Cursors and presence beats are only worth having now: in the ring they'd push out what a resume needs.
314
- if (!raw.startsWith(VOLATILE)) {
315
- made.ring.push(ev)
316
- trim(made)
317
- }
318
- for (const on of made.listeners) on(ev)
319
- }
320
- made.ready = bus.subscribe(name, made.got, () => {
321
- made.ring.length = 0
322
- made.floor = seq
323
- for (const on of made.listeners) on(null)
324
- }).then(stop => {
325
- made.stop = stop
326
- made.floor = seq
327
- }, e => {
328
- channels.delete(name) // the next use tries again; whoever awaits `ready` sees the failure
329
- throw e
330
- })
331
- made.ready.catch(() => {})
332
- return made
333
- }
334
-
335
- function unlisten(ch: Channel, on: (ev: Ev | null) => void) {
336
- ch.listeners.delete(on)
337
- if (ch.listeners.size || ch.idle) return
338
- ch.idle = unref(setTimeout(() => {
339
- ch.stop?.()
340
- channels.delete(ch.name)
341
- }, limits.window))
342
- }
343
-
344
- function replay(ch: Channel, after: string): Ev[] | undefined {
345
- const [e, n] = after.split(':')
346
- const at = Number(n)
347
- trim(ch)
348
- if (e !== epoch || !(at >= ch.floor)) return undefined
349
- return ch.ring.filter(ev => ev.seq > at)
350
- }
351
-
352
- /** Listens from the first realtime use on, not from boot: `weft build` loads this with no bus to reach. */
353
- function pokes(): Channel {
354
- const had = channels.get('weft:poke')
355
- if (had) return had
356
- const ch = channel('weft:poke')
357
- ch.listeners.add(ev => {
358
- if (!ev) {
359
- for (const c of conns.values()) c.sink.write('R', JSON.stringify({ c: cursorOf(seq) }))
360
- return
361
- }
362
- const m = JSON.parse(ev.raw) as Poke & { o?: string; b?: 1 }
363
- if (m.b) { // an external listener's heartbeat
364
- beat = Date.now()
365
- return status(undefined)
366
- }
367
- for (const c of conns.values()) if (c.k !== m.o) poke(c, picked(c, m), ev.seq)
368
- for (const w of watchers) w(m.t)
369
- })
370
- return ch
371
- }
372
-
373
- /** What this connection subscribed to. `k`: a mutation's poke already reaches a subscription with one of these tags. */
374
- function picked(c: Conn, { t, p, k }: Poke) {
375
- if (!p && !k) return t.filter(x => c.tags.has(x))
376
- const out = new Set<string>()
377
- for (const s of c.subs.values()) {
378
- if (k && s.tags.some(x => k.includes(x))) continue
379
- for (const x of s.tags) if (t.includes(x) || p?.some(pre => x.startsWith(pre))) out.add(x)
380
- }
381
- return [...out]
382
- }
383
-
384
- function poke(c: Conn, tags: string[], at: number) {
385
- if (!tags.length) return
386
- if (!c.held.size && c.sink.backlog() < HIGH) return c.sink.write('p', JSON.stringify({ t: tags, c: cursorOf(at) }))
387
- const p = (c.pokes ??= { t: new Set(), c: '' })
388
- for (const t of tags) p.t.add(t)
389
- p.c = cursorOf(at)
390
- }
391
-
392
- /** Over `queue`: the client gets a bye, reconnects and resumes, rather than this process growing. */
393
- function hold(c: Conn, s: Sub, raw: string): boolean {
394
- const size = raw.length
395
- if (!s.buffer) {
396
- for (const x of s.q) c.bytes -= x.size
397
- s.q.length = 0
398
- } else if (s.q.length >= s.buffer) {
399
- for (const x of s.q) c.bytes -= x.size
400
- s.q.length = 0
401
- return false
402
- }
403
- s.q.push({ raw, size })
404
- c.bytes += size
405
- c.held.add(s)
406
- if (c.bytes > limits.queue) bye(c, 0)
407
- return true
408
- }
409
-
410
- /** Written to the client, or held. False: the buffer overflowed, so this stream resets. */
411
- function deliver(c: Conn, s: Sub, raw: string): boolean {
412
- if (!c.held.size && c.sink.backlog() < HIGH) return c.sink.write('m', raw), true
413
- return hold(c, s, raw)
414
- }
415
-
416
- function drain(c: Conn) {
417
- if (c.pokes && c.sink.backlog() < HIGH) {
418
- c.sink.write('p', JSON.stringify({ t: [...c.pokes.t], c: c.pokes.c }))
419
- c.pokes = undefined
420
- }
421
- for (const s of c.held) {
422
- while (s.q.length && c.sink.backlog() < HIGH) {
423
- const x = s.q.shift()!
424
- c.bytes -= x.size
425
- c.sink.write('m', x.raw)
426
- }
427
- if (s.q.length) return
428
- c.held.delete(s)
429
- }
430
- }
431
-
432
- function bye(c: Conn, retry: number) {
433
- c.sink.write('b', JSON.stringify({ retry }))
434
- close(c)
435
- }
436
-
437
- function close(c: Conn) {
438
- if (!conns.delete(c.k)) return
439
- for (const t of c.timers) clearTimeout(t)
440
- for (const s of c.subs.values()) s.ac.abort()
441
- c.subs.clear()
442
- c.sink.close()
443
- rest()
444
- }
445
-
446
- /** A subscription refused before it existed. */
447
- const refuse = (c: Conn, n: number, e: StartError) => c.sink.write('e', JSON.stringify([n, { code: e.code, message: e.message }]))
448
-
449
- function fail(c: Conn, s: Sub, e: unknown) {
450
- remove(c, s)
451
- if (e instanceof StartError) return c.sink.write('e', JSON.stringify([s.n, { code: e.code, message: e.message, ...(e.extra.issues ? { issues: e.extra.issues } : {}) }]))
452
- s.options.onDiagnostic?.({ code: 'E_RT', severity: 'error', message: `${s.fn.$id}: ${e instanceof Error ? e.stack ?? e.message : String(e)}` })
453
- c.sink.write('e', JSON.stringify([s.n, { message: s.options.dev && e instanceof Error ? e.message : 'Subscription failed.' }]))
454
- }
455
-
456
- function remove(c: Conn, s: Sub) {
457
- s.ac.abort()
458
- c.subs.delete(s.n)
459
- c.held.delete(s)
460
- for (const x of s.q) c.bytes -= x.size
461
- for (const t of s.tags) {
462
- const left = c.tags.get(t)! - 1
463
- left ? c.tags.set(t, left) : c.tags.delete(t)
464
- }
465
- }
466
-
467
- async function add(c: Conn, entry: SubEntry, request: Request, scoped: ScopeOptions) {
468
- const [n, kind, id, input, cursor, buffer] = entry
469
- const fn = fns.get(id)
470
- if (c.subs.size >= limits.subscriptions) return refuse(c, n, new StartError('R009', `this connection already has ${limits.subscriptions} subscriptions`, 'read fewer keys, or raise `limits.subscriptions`'))
471
- if (!fn || fn.kind !== (kind === 'q' ? 'query' : 'stream')) return refuse(c, n, new StartError('Q009', `no ${kind === 'q' ? 'query' : 'stream'} \`${id}\` (the page is older than the server): reload`))
472
- if (c.subs.has(n)) return
473
- const s: Sub = { n, kind, fn, input, tags: [], buffer: buffer ?? 0, q: [], ac: new AbortController(), request, options: scoped }
474
- c.subs.set(n, s) // before any await: a `d` that follows finds it
475
- try {
476
- const valid = await validate(fn, input)
477
- if (s.ac.signal.aborted) return
478
- if (kind === 's') return void stream(c, s, valid, cursor)
479
- guard(fn, fn.def.auth ? await createScope(request, scoped).ctx() : ({} as Context))
480
- if (s.ac.signal.aborted) return
481
- s.tags = await tagsOf(fn, valid, createScope(request, scoped))
482
- for (const t of s.tags) c.tags.set(t, (c.tags.get(t) ?? 0) + 1)
483
- if (scoped.dev) {
484
- liveQueries.set(fn.$id, s.tags)
485
- if (source) missedFeed(fn, s.tags, source.tables, scoped.onDiagnostic)
486
- if (!s.tags.length) scoped.onDiagnostic?.({ code: 'W053', severity: 'error', message: `\`${fn.$id}\` is read with \`.live()\` but declares no \`tags\`: no mutation can ever poke it. Add \`tags\` to the query` })
487
- }
488
- c.sink.write('a', JSON.stringify([n, s.tags]))
489
- } catch (e) {
490
- fail(c, s, e)
491
- }
492
- }
493
-
494
- /** One stream's `run`: resumed with its cursor, restarted without one on a reset. */
495
- async function stream(c: Conn, s: Sub, input: unknown, cursor?: string) {
496
- const { signal } = s.ac
497
- for (;;) {
498
- try {
499
- const scope = createScope(s.request, s.options)
500
- const frame = s.options.dev ? Object.create(scope, { touched: { value: { read: new Set(), write: new Set() } } }) : scope
501
- await enter(frame, async () => {
502
- const ctx = await scope.ctx()
503
- guard(s.fn, ctx)
504
- const replaced = s.options.handlers?.get(s.fn.$id) as ((input: unknown, ctx: Context, o: { signal: AbortSignal; lastEventId: unknown }) => unknown) | undefined
505
- const out = replaced ? replaced(input, ctx, { signal, lastEventId: cursor }) : s.fn.def.run!({ input, ctx, signal, lastEventId: cursor })
506
- for await (const y of out as AsyncIterable<unknown>) {
507
- if (signal.aborted) return
508
- const [id, msg] = y instanceof Tracked ? [y.id, y.value] : [null, y]
509
- if (s.options.dev) await checked(s.fn, msg)
510
- const raw = JSON.stringify([s.n, id, msg])
511
- if (raw.length > limits.message) throw new StartError('R008', `\`${s.fn.$id}\` yielded ${raw.length} bytes; a message may be ${limits.message}`, 'send less, or an id the client fetches')
512
- if (id !== null) cursor = id
513
- if (!deliver(c, s, raw)) throw RESET
514
- }
515
- })
516
- if (signal.aborted) return
517
- remove(c, s)
518
- c.sink.write('f', JSON.stringify([s.n]))
519
- return
520
- } catch (e) {
521
- if (signal.aborted) return
522
- if (e !== RESET) return fail(c, s, e)
523
- cursor = undefined
524
- c.sink.write('r', JSON.stringify([s.n]))
525
- }
526
- }
527
- }
528
-
529
- async function change(c: Conn, request: Request, opts: ScopeOptions, body: Body) {
530
- for (const n of body.d ?? []) {
531
- const s = c.subs.get(n)
532
- if (s) remove(c, s)
533
- }
534
- await Promise.all((body.s ?? []).map(e => add(c, e, request, opts)))
535
- }
536
-
537
- function replayPokes(c: Conn, after: string) {
538
- const list = replay(pokes(), after)
539
- if (!list) return c.sink.write('R', JSON.stringify({ c: cursorOf(seq) }))
540
- for (const ev of list) {
541
- const m = JSON.parse(ev.raw) as Poke & { o?: string }
542
- if (m.o !== c.k) poke(c, picked(c, m), ev.seq)
543
- }
544
- }
545
-
546
- async function recheck(c: Conn) {
547
- const authed = [...c.subs.values()].filter(s => s.fn.def.auth)
548
- if (!authed.length) return
549
- const ctx = await createScope(authed[0]!.request, authed[0]!.options).ctx().catch(() => ({}) as Context)
550
- if (!conns.has(c.k)) return
551
- for (const s of authed) {
552
- try { guard(s.fn, ctx) } catch (e) { fail(c, s, e) }
553
- }
554
- }
555
-
556
- async function connect(request: Request, scope: Scope, body: Body, sink: Sink, line = false) {
557
- const opts = scope.options
558
- const ctx = await scope.ctx()
559
- const user = (ctx as { user?: { id?: string } }).user?.id
560
- if (!line && user !== undefined && [...conns.values()].filter(c => c.user === user).length >= limits.connections) {
561
- throw new StartError('R009', `user \`${user}\` already has ${limits.connections} realtime connections`, 'close tabs, or raise `limits.connections`', { status: 429 })
562
- }
563
- const c: Conn = { k: crypto.randomUUID(), sink, user, subs: new Map(), tags: new Map(), held: new Set(), bytes: 0, timers: [] }
564
- conns.set(c.k, c)
565
- await pokes().ready.catch(e => {
566
- conns.delete(c.k)
567
- throw e
568
- })
569
- await want() // before `h`: a client that reads next must not miss a change
570
- sink.write('h', JSON.stringify({ k: c.k, e: epoch, b: options.build ?? '', c: cursorOf(seq), ...(source && { l: wire() }) }))
571
- await change(c, request, opts, body)
572
- if (body.c) replayPokes(c, body.c)
573
- let ticks = 0
574
- const every = Math.max(1, Math.round(limits.session / limits.heartbeat))
575
- c.timers.push(unref(setInterval(() => {
576
- sink.ping?.()
577
- if (++ticks % every === 0) void recheck(c)
578
- }, limits.heartbeat)))
579
- if (options.maxDuration) c.timers.push(unref(setTimeout(() => bye(c, 0), Math.max(1, options.maxDuration - 10) * 1000)))
580
- return c
581
- }
582
-
583
- return {
584
- bus, limits, cursorOf, channel, replay, unlisten, connect, change, drain, close, closing, epoch, dev: options.dev, maxDuration: options.maxDuration, build: options.build, share: options.share,
585
- position: () => cursorOf(seq),
586
- watch(fn) {
587
- watchers.add(fn)
588
- void pokes()
589
- },
590
- find: k => conns.get(k),
591
- connections: () => [...conns.values()],
592
- cursor: () => (pokes().stop ? cursorOf(seq) : undefined),
593
- ready: () => {
594
- const on = Promise.resolve(want()).then(rest)
595
- return Promise.race([Promise.all([pokes().ready.catch(() => {}), on]), new Promise(ok => { unref(setTimeout(ok, 250)) })])
596
- },
597
- async poke(tags, origin) {
598
- if (!tags.length) return
599
- // In this order: a client's refetch must start after what the barrier claims is covered.
600
- if (live.mode === 'feed' || live.mode === 'external') await feed?.settle(tags)
601
- await bus.publish('weft:poke', JSON.stringify({ t: tags, ...(origin && { o: origin }) }))
602
- },
603
- push(id, msg) {
604
- let hit = 0
605
- for (const c of conns.values()) {
606
- for (const s of c.subs.values()) {
607
- if (s.fn.$id !== id) continue
608
- hit++
609
- deliver(c, s, JSON.stringify([s.n, null, msg]))
610
- }
611
- }
612
- return hit
613
- },
614
- stats: () => ({ connections: conns.size, subscriptions: [...conns.values()].reduce((n, c) => n + c.subs.size, 0), channels: channels.size }),
615
- live: () => live,
98
+ const { pokes, replayPokes } = presence
99
+ const feedState = createFeedState(options, limits, conns, pokes)
100
+ const { want, rest, wire, probed, feed } = feedState
101
+ const connections = createConnections({
102
+ conns,
103
+ limits,
104
+ epoch,
105
+ options,
106
+ source,
107
+ pokes,
108
+ want,
109
+ rest,
110
+ cursorOf,
111
+ seq,
112
+ wire,
113
+ change,
114
+ replayPokes,
115
+ fail,
116
+ })
117
+ const { connect, close } = connections
118
+
119
+ return createHubView({
120
+ bus,
121
+ limits,
122
+ epoch,
123
+ options,
124
+ channels,
125
+ conns,
126
+ watchers,
127
+ closing,
128
+ cursorOf,
129
+ channel,
130
+ replay,
131
+ unlisten,
132
+ seq,
133
+ pokes,
134
+ want,
135
+ rest,
136
+ feedState,
137
+ feed,
616
138
  probed,
617
- required: !!options.required?.length,
618
- get refused() { return refused },
619
- async shutdown() {
620
- clearTimeout(resting)
621
- clearInterval(beats)
622
- await feed?.stop()
623
- for (const fn of closing) fn()
624
- for (const c of conns.values()) close(c)
625
- for (const ch of channels.values()) {
626
- clearTimeout(ch.idle)
627
- ch.stop?.()
628
- }
629
- channels.clear()
630
- await bus.close?.()
631
- },
632
- }
633
- }
634
-
635
- async function checked(fn: Fn, msg: unknown) {
636
- const r = await fn.def.message?.['~standard'].validate(msg)
637
- if (r?.issues) throw new StartError('C002', `\`${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('; ')}`, 'fix `run`, or the schema', { status: 500 })
139
+ deliver,
140
+ drain,
141
+ connect,
142
+ close,
143
+ change,
144
+ })
638
145
  }
639
146
 
640
147
  let current: Hub | undefined
@@ -653,8 +160,14 @@ export const realtimeStats = () => hub().stats()
653
160
  * database back, and it recovers by itself.
654
161
  */
655
162
  export function liveHealth(): LiveState & { ok: boolean; required: boolean } {
656
- const h = hub(), s = h.live(), required = h.required
657
- return { ...s, required, ok: !required || ((s.mode === 'feed' || s.mode === 'external') && !s.down) }
163
+ const h = hub(),
164
+ s = h.live(),
165
+ required = h.required
166
+ return {
167
+ ...s,
168
+ required,
169
+ ok: !required || ((s.mode === 'feed' || s.mode === 'external') && !s.down),
170
+ }
658
171
  }
659
172
  /** @weft-js/core/test, after each test. */
660
173
  export const resetHub = () => {
@@ -663,160 +176,21 @@ export const resetHub = () => {
663
176
  return old?.shutdown()
664
177
  }
665
178
 
666
- // ---- topics ---------------------------------------------------------------------------------------------------------
667
-
668
- /** What `topic()` returns: publish on the server, follow with `.live`. */
669
- export interface Topic<K, M> {
670
- /** Fan a message out to every subscriber, on every instance. */
671
- publish(key: K, message: M): Promise<void>
672
- /**
673
- * The messages of `keys`. Listens from this call, so a read that follows can't miss one (R006's gap): call it before
674
- * reading a backlog. With `after`, replays what the ring still has; outside the window the stream resets.
675
- */
676
- subscribe(keys: K | K[], options?: { signal?: AbortSignal | undefined; after?: string | undefined }): AsyncGenerator<Tracked<M>, void, void>
677
- readonly name: string
678
- readonly message?: StandardSchemaV1<unknown, M>
679
- }
680
-
179
+ export type { Topic } from './hub/topic'
681
180
  type Out<S> = S extends StandardSchemaV1<unknown, infer O> ? O : never
682
-
683
181
  /** A typed server channel: mutations, jobs and webhooks publish, streams read. */
684
- export function topic<K extends StandardSchemaV1<unknown, string | number>, M extends StandardSchemaV1>(name: string, def: { key?: K; message: M }): Topic<Out<K>, Out<M>> {
685
- const path = (key: unknown) => `weft:t:${name}:${String(key)}`
686
- return {
687
- name,
688
- message: def.message as StandardSchemaV1<unknown, Out<M>>,
689
- async publish(key, message) {
690
- const raw = JSON.stringify(message)
691
- const h = hub()
692
- if (raw.length > h.limits.message) throw new StartError('R008', `a \`${name}\` message is ${raw.length} bytes; a message may be ${h.limits.message}`, 'publish less, or an id subscribers fetch')
693
- await h.bus.publish(path(key), raw)
694
- },
695
- subscribe(keys, { signal, after } = {}) {
696
- const h = hub()
697
- if (currentScope()?.touched?.read.size) currentScope()!.options.onDiagnostic?.({ code: 'R006', severity: 'warning', message: `a stream read ${[...currentScope()!.touched!.read].join(', ')} before \`${name}.subscribe()\`: anything published in between is lost. Call \`subscribe\` first, or pass \`after\`` })
698
- const chans = (Array.isArray(keys) ? keys : [keys]).map(k => h.channel(path(k)))
699
- const q: Ev[] = []
700
- let wake: (() => void) | undefined
701
- let lost = false
702
- const on = (ev: Ev | null) => {
703
- if (ev) q.push(ev)
704
- else lost = true
705
- if (q.length > h.limits.ring) lost = true
706
- wake?.()
707
- }
708
- for (const ch of chans) ch.listeners.add(on) // before anything is read: that's the rule
709
- const stop = () => { for (const ch of chans) h.unlisten(ch, on) }
710
- const abort = () => wake?.()
711
- signal?.addEventListener('abort', abort)
712
- return (async function* () {
713
- try {
714
- await Promise.all(chans.map(c => c.ready))
715
- if (after) {
716
- const seen = new Set(q.map(e => e.seq))
717
- for (const ch of chans) {
718
- const back = h.replay(ch, after)
719
- if (!back) throw RESET
720
- for (const ev of back) if (!seen.has(ev.seq)) q.push(ev)
721
- }
722
- q.sort((a, b) => a.seq - b.seq)
723
- }
724
- for (;;) {
725
- if (signal?.aborted) return
726
- if (lost) throw RESET
727
- const ev = q.shift()
728
- if (ev) yield tracked(h.cursorOf(ev.seq), JSON.parse(ev.raw))
729
- else await new Promise<void>(ok => { wake = ok })
730
- }
731
- } finally {
732
- signal?.removeEventListener('abort', abort)
733
- stop()
734
- }
735
- })()
736
- },
737
- }
738
- }
739
-
740
- // ---- the HTTP route -------------------------------------------------------------------------------------------------
741
-
742
- const enc = new TextEncoder()
743
- /** Whether a request body has the shape of `Body`. */
744
- export const validBody = (input: unknown): input is Body => {
745
- const b = input as Partial<Record<keyof Body, unknown>> | null | undefined
746
- return !!b && typeof b === 'object' && !Array.isArray(b) &&
747
- (b.k === undefined || typeof b.k === 'string') && (b.c === undefined || typeof b.c === 'string') &&
748
- (b.d === undefined || (Array.isArray(b.d) && b.d.every((n: unknown) => typeof n === 'number'))) &&
749
- (b.s === undefined || (Array.isArray(b.s) && b.s.every((e: unknown) => Array.isArray(e) && typeof e[0] === 'number' && (e[1] === 'q' || e[1] === 's') && typeof e[2] === 'string')))
750
- }
751
-
752
- /** `/_weft/rt` (research 30 §4): opens a connection, or changes one. Same proof as a mutation, plus `origins`. */
753
- export async function rtRoute(request: Request, scope: Scope, origins: string[] = []): Promise<Response> {
754
- const refuse = (code: string, message: string, status: number) => {
755
- scope.options.onDiagnostic?.({ code, severity: 'error', message })
756
- return Response.json({ e: { code, message } }, { status, headers: { 'cache-control': 'no-store' } })
757
- }
758
- if (request.method !== 'POST') return refuse('Q010', 'Q010: `/_weft/rt` needs POST', 405)
759
- const url = new URL(request.url)
760
- const origin = request.headers.get('origin')
761
- const site = request.headers.get('sec-fetch-site')
762
- const allowed = origin && origins.includes(origin)
763
- if (!allowed && ((origin && origin !== url.origin) || (site && site !== 'same-origin' && site !== 'none'))) return refuse('R010', `R010: origin \`${origin ?? site}\` may not open a realtime connection: list it in \`realtime({ origins })\``, 403)
764
- if (request.headers.get('x-weft') !== '1' || !request.headers.get('content-type')?.startsWith('application/json')) return refuse('Q010', 'Q010: `/_weft/rt` without the CSRF proof (POST + JSON + `x-weft: 1`)', 403)
765
- const h = hub()
766
- const raw = await request.text()
767
- if (raw.length > h.limits.body) return refuse('R008', `R008: a realtime request of ${raw.length} bytes; the limit is ${h.limits.body}`, 413)
768
- let body: Body
769
- try {
770
- body = JSON.parse(raw)
771
- } catch {
772
- return refuse('Q002', 'Q002: `/_weft/rt` got a body that isn\'t JSON', 400)
773
- }
774
- // A tab that didn't dial the shared socket asks who it is now (link.ts).
775
- if ((body as { w?: 1 })?.w === 1) return Response.json({ u: await sessionOf(await scope.ctx()) }, { headers: { 'cache-control': 'no-store' } })
776
- if (!validBody(body)) return refuse('Q002', 'Q002: `/_weft/rt` got a body of the wrong shape', 400)
777
- if (body.k) {
778
- const c = h.find(body.k)
779
- const user = c && ((await scope.ctx()) as { user?: { id?: string } }).user?.id
780
- if (!c || c.user !== user) return refuse('Q009', 'Q009: no such realtime connection (reconnect with the whole set)', 404)
781
- await h.change(c, request, scope.options, body)
782
- return new Response(null, { status: 204 })
783
- }
784
- let conn: Conn | undefined
785
- let queued = 0
786
- let controller!: ReadableStreamDefaultController<Uint8Array>
787
- const push = (text: string) => {
788
- const bytes = enc.encode(text)
789
- queued += bytes.length
790
- controller.enqueue(bytes)
791
- }
792
- const sink: Sink = {
793
- write: (event, data) => push(`event: ${event}\ndata: ${data}\n\n`),
794
- ping: () => push(': ping\n\n'),
795
- backlog: () => queued,
796
- close: () => { try { controller.close() } catch {} },
797
- }
798
- const out = new ReadableStream<Uint8Array>(
799
- {
800
- start: c => void (controller = c),
801
- // The reader took what was queued: the strategy's hwm of 0 means every pull is an empty queue.
802
- pull() {
803
- queued = 0
804
- if (conn) h.drain(conn)
805
- },
806
- cancel() {
807
- if (conn) h.close(conn)
808
- else closed = true
809
- },
810
- },
811
- { highWaterMark: 0 },
812
- )
813
- let closed = false
814
- try {
815
- conn = await h.connect(request, scope, body, sink)
816
- } catch (e) {
817
- if (e instanceof StartError) return refuse(e.code, e.message, (e.extra.status as number) ?? 500)
818
- throw e
819
- }
820
- if (closed) h.close(conn)
821
- return new Response(out, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-store', 'x-accel-buffering': 'no', connection: 'keep-alive' } })
182
+ export function topic<
183
+ K extends StandardSchemaV1<unknown, string | number>,
184
+ M extends StandardSchemaV1,
185
+ >(name: string, def: { key?: K; message: M }): Topic<Out<K>, Out<M>> {
186
+ return makeTopic(name, def, hub)
187
+ }
188
+
189
+ /** `/_weft/rt` opens a connection or changes one. */
190
+ export async function rtRoute(
191
+ request: Request,
192
+ scope: Scope,
193
+ origins: string[] = [],
194
+ ): Promise<Response> {
195
+ return handleRoute(request, scope, origins, hub)
822
196
  }