@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.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 {
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
16
|
-
import
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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('*')))
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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 =
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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(),
|
|
657
|
-
|
|
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
|
-
|
|
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<
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
}
|