@weft-js/start 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -3
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -0
- package/crate/rust-toolchain.toml +3 -0
- package/crate/src/build/emit.rs +103 -0
- package/crate/src/build.rs +367 -0
- package/crate/src/ctx.rs +186 -0
- package/crate/src/envelope.rs +163 -0
- package/crate/src/functions.rs +173 -0
- package/crate/src/lib.rs +118 -0
- package/crate/src/native.rs +212 -0
- package/crate/src/otel.rs +62 -0
- package/crate/src/server.rs +203 -0
- package/package.json +46 -37
- package/src/app.mjs +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/socket.ts
CHANGED
|
@@ -8,9 +8,16 @@
|
|
|
8
8
|
// shared state last writer wins by `(at, instance)`. Instances hold the value; a new listener asks on the bus.
|
|
9
9
|
|
|
10
10
|
import type { PublishOptions } from './channel'
|
|
11
|
-
import { enter, fns, FnError, isRedirect, runtime, stable, StartError, type Context
|
|
12
|
-
import { createScope, type Scope
|
|
13
|
-
import {
|
|
11
|
+
import { enter, fns, FnError, isRedirect, runtime, stable, StartError, type Context } from './data'
|
|
12
|
+
import { createScope, type Scope } from './exec'
|
|
13
|
+
import { hub, type Ev, type Hub } from './hub'
|
|
14
|
+
import type { Join, Sock, Hubbed, Value } from './socket/types'
|
|
15
|
+
import { room, leaveRoom, store, VALUE } from './socket/rooms'
|
|
16
|
+
import { take, check, allowed, denied } from './socket/check'
|
|
17
|
+
import { deliver } from './socket/deliver'
|
|
18
|
+
import { sharedWriter } from './socket/shared'
|
|
19
|
+
import { publish } from './socket/publish'
|
|
20
|
+
import { socketRouteWith } from './socket/route'
|
|
14
21
|
import { SHARE, shareChannel } from './share-server'
|
|
15
22
|
|
|
16
23
|
/** A socket as this file needs it. `send` takes a whole frame. */
|
|
@@ -27,35 +34,18 @@ export interface Attached {
|
|
|
27
34
|
recheck(): Promise<void>
|
|
28
35
|
}
|
|
29
36
|
|
|
30
|
-
interface Join { n: number; fn: Fn; params: unknown; room?: Room; on?: (ev: Ev | null) => void; bucket: Bucket; gone?: boolean; chain: Promise<unknown> }
|
|
31
|
-
interface Bucket { tokens: number; at: number }
|
|
32
|
-
interface Sock {
|
|
33
|
-
k: string
|
|
34
|
-
peer: Peer
|
|
35
|
-
request: Request
|
|
36
|
-
options: ScopeOptions
|
|
37
|
-
ctx: Context
|
|
38
|
-
user?: string | undefined
|
|
39
|
-
joins: Map<number, Join>
|
|
40
|
-
bucket: Bucket
|
|
41
|
-
last: number
|
|
42
|
-
timers: ReturnType<typeof setTimeout>[]
|
|
43
|
-
}
|
|
44
|
-
interface Room { name: string; ch: Channel; refs: number; shared: boolean; on: (ev: Ev | null) => void }
|
|
45
|
-
interface Value { v: unknown; at: number; o: string }
|
|
46
|
-
interface Hubbed { socks: Set<Sock>; rooms: Map<string, Room>; values: Map<string, Value> }
|
|
47
|
-
|
|
48
37
|
/** A hub's sockets: a new hub (a dev reload, a test) starts empty. */
|
|
49
38
|
const states = new WeakMap<Hub, Hubbed>()
|
|
50
|
-
const VALUES = 10_000
|
|
51
39
|
|
|
52
40
|
function stateOf(h: Hub): Hubbed {
|
|
53
41
|
const s = states.get(h)
|
|
54
42
|
if (s) return s
|
|
55
43
|
const made: Hubbed = { socks: new Set(), rooms: new Map(), values: new Map() }
|
|
56
44
|
states.set(h, made)
|
|
57
|
-
h.closing.add(() => {
|
|
58
|
-
|
|
45
|
+
h.closing.add(() => {
|
|
46
|
+
for (const c of Array.from(made.socks)) gone(h, c, 1001)
|
|
47
|
+
})
|
|
48
|
+
h.watch((tags) => {
|
|
59
49
|
for (const t of tags) {
|
|
60
50
|
if (!t.startsWith('session:')) continue
|
|
61
51
|
for (const c of made.socks) if (c.user === t.slice(8)) void recheck(h, c)
|
|
@@ -64,92 +54,57 @@ function stateOf(h: Hub): Hubbed {
|
|
|
64
54
|
return made
|
|
65
55
|
}
|
|
66
56
|
|
|
67
|
-
const unref = <T
|
|
57
|
+
const unref = <T>(t: T) => ((t as { unref?(): void } | null | undefined)?.unref?.(), t)
|
|
68
58
|
const roomName = (id: string, params: unknown) => `weft:c:${id}:${stable(params)}`
|
|
69
|
-
const SET = '$set'
|
|
70
|
-
|
|
71
|
-
function take(b: Bucket, perSecond: number, burst: number) {
|
|
72
|
-
const now = Date.now()
|
|
73
|
-
b.tokens = Math.min(burst, b.tokens + ((now - b.at) * perSecond) / 1000)
|
|
74
|
-
b.at = now
|
|
75
|
-
if (b.tokens < 1) return false
|
|
76
|
-
b.tokens--
|
|
77
|
-
return true
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function check(schema: StandardSchemaV1 | undefined, value: unknown, what: string) {
|
|
81
|
-
if (!schema) return value
|
|
82
|
-
const r = await schema['~standard'].validate(value)
|
|
83
|
-
if (!r.issues) return r.value
|
|
84
|
-
throw new StartError('Q002', `${what} was refused: ${r.issues.map(i => i.message).join('; ')}`, 'send what the schema accepts', { status: 400, issues: r.issues.map(i => ({ message: i.message, path: i.path?.map(p => (typeof p === 'object' ? p.key : p)) })) })
|
|
85
|
-
}
|
|
59
|
+
const SET = '$set'
|
|
86
60
|
|
|
87
61
|
const send = (c: Sock, out: unknown[]) => {
|
|
88
62
|
c.peer.send(JSON.stringify(out))
|
|
89
63
|
if (c.peer.backlog() > hub().limits.queue) gone(hub(), c, 1013)
|
|
90
64
|
}
|
|
91
65
|
|
|
92
|
-
const refuse = (c: Sock, n: number, e: { code?: string | undefined; message: string }) =>
|
|
93
|
-
|
|
94
|
-
type Authorize = ((args: { ctx: Context; params: unknown }) => unknown) & Record<'read' | 'write', (args: { ctx: Context; key: unknown }) => unknown>
|
|
95
|
-
const allowed = async (fn: Fn, ctx: Context, params: unknown, write?: boolean): Promise<boolean> => {
|
|
96
|
-
const a = fn.def.authorize as Authorize
|
|
97
|
-
return !!(await (fn.kind === 'shared' ? a[write ? 'write' : 'read']({ ctx, key: params }) : a({ ctx, params })))
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const denied = (fn: Fn) => new StartError('R011', `\`${fn.$id}\`: \`authorize\` refused this client`, 'sign in, or check `authorize`')
|
|
101
|
-
|
|
102
|
-
/** Fan-out of a room's messages to one join. */
|
|
103
|
-
function deliver(c: Sock, j: Join, h: Hub, ev: Ev) {
|
|
104
|
-
const m = (ev.p ??= JSON.parse(ev.raw)) as { f?: string; x?: 1; t?: string; n: string; m: unknown }
|
|
105
|
-
if (m.n === ASK || (m.x && m.f === c.k) || (m.t && m.t !== c.k)) return
|
|
106
|
-
send(c, ['m', j.n, h.cursorOf(ev.seq), m.n, m.m])
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function room(h: Hub, st: Hubbed, name: string, shared: boolean): Room {
|
|
110
|
-
let r = st.rooms.get(name)
|
|
111
|
-
if (r) return r.refs++, r
|
|
112
|
-
const ch = h.channel(name)
|
|
113
|
-
const on = (ev: Ev | null) => {
|
|
114
|
-
if (!ev || !shared) return
|
|
115
|
-
const m = (ev.p ??= JSON.parse(ev.raw)) as { n: string; m: never }
|
|
116
|
-
if (m.n === VALUE) store(st, name, m.m)
|
|
117
|
-
else if (m.n === ASK) {
|
|
118
|
-
const v = st.values.get(name)
|
|
119
|
-
if (v) void h.bus.publish(name, JSON.stringify({ n: VALUE, m: v }))
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
ch.listeners.add(on)
|
|
123
|
-
r = { name, ch, refs: 1, shared, on }
|
|
124
|
-
st.rooms.set(name, r)
|
|
125
|
-
if (shared && !st.values.has(name)) void ch.ready.then(() => h.bus.publish(name, JSON.stringify({ n: ASK })))
|
|
126
|
-
return r
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function leaveRoom(h: Hub, st: Hubbed, r: Room) {
|
|
130
|
-
if (--r.refs) return
|
|
131
|
-
st.rooms.delete(r.name)
|
|
132
|
-
h.unlisten(r.ch, r.on)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function store(st: Hubbed, name: string, v: Value) {
|
|
136
|
-
const had = st.values.get(name)
|
|
137
|
-
if (had && (had.at > v.at || (had.at === v.at && had.o >= v.o))) return
|
|
138
|
-
st.values.delete(name) // last in the map: the oldest key goes first
|
|
139
|
-
st.values.set(name, v)
|
|
140
|
-
if (st.values.size > VALUES) st.values.delete(st.values.keys().next().value!)
|
|
141
|
-
}
|
|
66
|
+
const refuse = (c: Sock, n: number, e: { code?: string | undefined; message: string }) =>
|
|
67
|
+
send(c, ['e', n, { code: e.code, message: e.message }])
|
|
142
68
|
|
|
143
|
-
async function join(
|
|
69
|
+
async function join(
|
|
70
|
+
h: Hub,
|
|
71
|
+
c: Sock,
|
|
72
|
+
want: { n: number; id: string; params: unknown; cursor?: string | undefined },
|
|
73
|
+
) {
|
|
74
|
+
const { n, id, params, cursor } = want
|
|
144
75
|
const st = stateOf(h)
|
|
145
76
|
if (c.joins.has(n) || typeof n !== 'number') return
|
|
146
77
|
const fn = id === SHARE ? shareChannel(h) : fns.get(id)
|
|
147
|
-
if (fn?.kind !== 'channel' && fn?.kind !== 'shared')
|
|
148
|
-
|
|
149
|
-
|
|
78
|
+
if (fn?.kind !== 'channel' && fn?.kind !== 'shared')
|
|
79
|
+
return refuse(
|
|
80
|
+
c,
|
|
81
|
+
n,
|
|
82
|
+
new StartError('Q009', `no channel \`${id}\` (the page is older than the server): reload`),
|
|
83
|
+
)
|
|
84
|
+
if (c.joins.size >= h.limits.subscriptions)
|
|
85
|
+
return refuse(
|
|
86
|
+
c,
|
|
87
|
+
n,
|
|
88
|
+
new StartError(
|
|
89
|
+
'R009',
|
|
90
|
+
`this socket already has ${h.limits.subscriptions} joins`,
|
|
91
|
+
'join fewer rooms, or raise `limits.subscriptions`',
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
const j: Join = {
|
|
95
|
+
n,
|
|
96
|
+
fn,
|
|
97
|
+
params,
|
|
98
|
+
bucket: { tokens: h.limits.burst, at: Date.now() },
|
|
99
|
+
chain: Promise.resolve(),
|
|
100
|
+
}
|
|
150
101
|
c.joins.set(n, j)
|
|
151
102
|
try {
|
|
152
|
-
j.params = await check(
|
|
103
|
+
j.params = await check(
|
|
104
|
+
fn.kind === 'shared' ? fn.def.key : fn.def.params,
|
|
105
|
+
params,
|
|
106
|
+
`\`${id}\`'s params`,
|
|
107
|
+
)
|
|
153
108
|
if (!(await allowed(fn, c.ctx, j.params))) throw denied(fn)
|
|
154
109
|
} catch (e) {
|
|
155
110
|
c.joins.delete(n)
|
|
@@ -159,11 +114,12 @@ async function join(h: Hub, c: Sock, n: number, id: string, params: unknown, cur
|
|
|
159
114
|
const name = roomName(id, j.params)
|
|
160
115
|
const r = (j.room = room(h, st, name, fn.kind === 'shared'))
|
|
161
116
|
const pending: Ev[] = []
|
|
162
|
-
let live = false,
|
|
117
|
+
let live = false,
|
|
118
|
+
lost = false
|
|
163
119
|
// Listen, then replay: a message published in between can't be lost (R006's rule).
|
|
164
|
-
j.on = ev => {
|
|
165
|
-
if (!ev) return live ?
|
|
166
|
-
if (live) deliver(c, j, h, ev)
|
|
120
|
+
j.on = (ev) => {
|
|
121
|
+
if (!ev) return live ? send(c, ['r', n, h.position()]) : void (lost = true)
|
|
122
|
+
if (live) deliver(c, j, h, ev, send)
|
|
167
123
|
else pending.push(ev)
|
|
168
124
|
}
|
|
169
125
|
r.ch.listeners.add(j.on)
|
|
@@ -180,8 +136,11 @@ async function join(h: Hub, c: Sock, n: number, id: string, params: unknown, cur
|
|
|
180
136
|
send(c, ['r', n, h.position()])
|
|
181
137
|
pending.length = 0
|
|
182
138
|
} else {
|
|
183
|
-
const seen = new Set(back.map(e => e.seq))
|
|
184
|
-
for (const ev of [...back, ...pending.filter(e => !seen.has(e.seq))].sort(
|
|
139
|
+
const seen = new Set(back.map((e) => e.seq))
|
|
140
|
+
for (const ev of [...back, ...pending.filter((e) => !seen.has(e.seq))].sort(
|
|
141
|
+
(a, b) => a.seq - b.seq,
|
|
142
|
+
))
|
|
143
|
+
deliver(c, j, h, ev, send)
|
|
185
144
|
}
|
|
186
145
|
live = true
|
|
187
146
|
const v = st.values.get(name)
|
|
@@ -198,46 +157,86 @@ function leave(h: Hub, c: Sock, j: Join) {
|
|
|
198
157
|
}
|
|
199
158
|
}
|
|
200
159
|
|
|
201
|
-
const bound = (c: Sock, j: Join) => ({
|
|
160
|
+
const bound = (c: Sock, j: Join) => ({
|
|
161
|
+
params: j.params,
|
|
162
|
+
publish: (n: string, m: unknown, o?: PublishOptions) =>
|
|
163
|
+
publish(j.fn, j.params, n, m, { ...o, from: c.k }),
|
|
164
|
+
})
|
|
202
165
|
|
|
203
166
|
/** To this connection, past the bus: only `share` uses it, and it has already picked who hears what. */
|
|
204
|
-
const reply = (h: Hub, c: Sock, j: Join) => (name: string, msg: unknown) => {
|
|
167
|
+
const reply = (h: Hub, c: Sock, j: Join) => (name: string, msg: unknown) => {
|
|
168
|
+
if (!j.gone) send(c, ['m', j.n, h.position(), name, msg])
|
|
169
|
+
}
|
|
205
170
|
|
|
206
171
|
/** `def.left`, once per join that got in, however it ended. Nothing is listening for a failure, so it's reported. */
|
|
207
172
|
async function left(c: Sock, j: Join) {
|
|
208
173
|
const scope: Scope = { ...createScope(c.request, c.options), ctx: () => Promise.resolve(c.ctx) }
|
|
209
174
|
try {
|
|
210
|
-
await enter(scope, () =>
|
|
175
|
+
await enter(scope, () =>
|
|
176
|
+
j.fn.def.left!({ id: c.k, ctx: c.ctx, params: j.params, room: bound(c, j) }),
|
|
177
|
+
)
|
|
211
178
|
} catch (e) {
|
|
212
179
|
known(c, j.fn.$id, e)
|
|
213
180
|
}
|
|
214
181
|
}
|
|
215
182
|
|
|
216
|
-
const known = (
|
|
217
|
-
|
|
218
|
-
|
|
183
|
+
const known = (
|
|
184
|
+
c: Sock,
|
|
185
|
+
id: string,
|
|
186
|
+
e: unknown,
|
|
187
|
+
): { code?: string | undefined; message: string; issues?: unknown } => {
|
|
188
|
+
if (e instanceof StartError || e instanceof FnError)
|
|
189
|
+
return { code: e.code, message: e.message, ...(e.issues && { issues: e.issues }) }
|
|
190
|
+
c.options.onDiagnostic?.({
|
|
191
|
+
code: 'E_RT',
|
|
192
|
+
severity: 'error',
|
|
193
|
+
message: `${id}: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`,
|
|
194
|
+
})
|
|
219
195
|
return { message: c.options.dev && e instanceof Error ? e.message : 'Message failed.' }
|
|
220
196
|
}
|
|
221
197
|
|
|
222
198
|
/** A message from the client: validated, its handler run in a fresh scope, the ack sent. In order within a join. */
|
|
223
|
-
async function run(h: Hub, c: Sock, j: Join, ack: number
|
|
199
|
+
async function run(h: Hub, c: Sock, j: Join, call: { ack: number; name: string; msg: unknown }) {
|
|
200
|
+
const { ack, name, msg } = call
|
|
224
201
|
const fn = j.fn
|
|
225
202
|
try {
|
|
226
203
|
if (fn.kind === 'shared') {
|
|
227
204
|
if (name !== SET) throw new StartError('Q002', `\`${fn.$id}\` takes no message \`${name}\``)
|
|
228
205
|
const v = await check(fn.def.value, msg, `\`${fn.$id}\`'s value`)
|
|
229
206
|
if (!(await allowed(fn, c.ctx, j.params, true))) throw denied(fn)
|
|
230
|
-
const st = stateOf(h),
|
|
231
|
-
|
|
207
|
+
const st = stateOf(h),
|
|
208
|
+
key = j.room!.name
|
|
209
|
+
const stamp: Value = {
|
|
210
|
+
v,
|
|
211
|
+
at: Math.max(Date.now(), (st.values.get(key)?.at ?? 0) + 1),
|
|
212
|
+
o: h.epoch,
|
|
213
|
+
}
|
|
232
214
|
store(st, key, stamp)
|
|
233
215
|
await h.bus.publish(key, JSON.stringify({ f: c.k, n: VALUE, m: stamp }))
|
|
234
216
|
} else {
|
|
235
217
|
const schema = Object.hasOwn(fn.def.client ?? {}, name) ? fn.def.client![name] : undefined
|
|
236
218
|
const handler = Object.hasOwn(fn.def.on ?? {}, name) ? fn.def.on![name] : undefined
|
|
237
|
-
if (!schema || !handler)
|
|
219
|
+
if (!schema || !handler)
|
|
220
|
+
throw new StartError(
|
|
221
|
+
'Q002',
|
|
222
|
+
`\`${fn.$id}\` takes no message \`${name}\``,
|
|
223
|
+
'name one of `client`, with a handler in `on`',
|
|
224
|
+
)
|
|
238
225
|
const valid = await check(schema, msg, `\`${fn.$id}.${name}\``)
|
|
239
|
-
const scope: Scope = {
|
|
240
|
-
|
|
226
|
+
const scope: Scope = {
|
|
227
|
+
...createScope(c.request, c.options),
|
|
228
|
+
ctx: () => Promise.resolve(c.ctx),
|
|
229
|
+
}
|
|
230
|
+
await enter(scope, () =>
|
|
231
|
+
handler({
|
|
232
|
+
msg: valid,
|
|
233
|
+
id: c.k,
|
|
234
|
+
ctx: c.ctx,
|
|
235
|
+
params: j.params,
|
|
236
|
+
room: bound(c, j),
|
|
237
|
+
reply: reply(h, c, j),
|
|
238
|
+
} as never),
|
|
239
|
+
)
|
|
241
240
|
}
|
|
242
241
|
if (!j.gone) send(c, ['a', j.n, ack])
|
|
243
242
|
} catch (e) {
|
|
@@ -246,32 +245,53 @@ async function run(h: Hub, c: Sock, j: Join, ack: number, name: string, msg: unk
|
|
|
246
245
|
}
|
|
247
246
|
}
|
|
248
247
|
|
|
248
|
+
function slow(h: Hub, c: Sock, what: string) {
|
|
249
|
+
c.options.onDiagnostic?.({
|
|
250
|
+
code: 'R008',
|
|
251
|
+
severity: 'error',
|
|
252
|
+
message: `R008: a client sent over ${what}`,
|
|
253
|
+
})
|
|
254
|
+
return gone(h, c, 1008, 'rate limit')
|
|
255
|
+
}
|
|
256
|
+
|
|
249
257
|
function frame(h: Hub, c: Sock, text: string) {
|
|
250
258
|
if (!stateOf(h).socks.has(c)) return // closing: what was already on the wire
|
|
251
259
|
c.last = Date.now()
|
|
252
260
|
if (text.length > h.limits.body) {
|
|
253
|
-
c.options.onDiagnostic?.({
|
|
261
|
+
c.options.onDiagnostic?.({
|
|
262
|
+
code: 'R008',
|
|
263
|
+
severity: 'error',
|
|
264
|
+
message: `R008: a channel frame of ${text.length} bytes; the limit is ${h.limits.body}`,
|
|
265
|
+
})
|
|
254
266
|
return gone(h, c, 1009, 'too big')
|
|
255
267
|
}
|
|
256
268
|
let f: unknown
|
|
257
|
-
try {
|
|
269
|
+
try {
|
|
270
|
+
f = JSON.parse(text)
|
|
271
|
+
} catch {
|
|
272
|
+
return gone(h, c, 1007, 'not JSON')
|
|
273
|
+
}
|
|
258
274
|
if (!Array.isArray(f)) return gone(h, c, 1007, 'not a frame')
|
|
259
275
|
if (f[0] === 'p') return send(c, ['p'])
|
|
260
276
|
if (!take(c.bucket, h.limits.perSecond, h.limits.burst)) {
|
|
261
|
-
|
|
262
|
-
return gone(h, c, 1008, 'rate limit')
|
|
277
|
+
return slow(h, c, `${h.limits.perSecond} messages a second`)
|
|
263
278
|
}
|
|
264
|
-
if (f[0] === 'j' && typeof f[2] === 'string')
|
|
279
|
+
if (f[0] === 'j' && typeof f[2] === 'string')
|
|
280
|
+
return void join(h, c, {
|
|
281
|
+
n: f[1],
|
|
282
|
+
id: f[2],
|
|
283
|
+
params: f[3],
|
|
284
|
+
cursor: typeof f[4] === 'string' ? f[4] : undefined,
|
|
285
|
+
})
|
|
265
286
|
const j = c.joins.get(f[1])
|
|
266
|
-
if (f[0] === 'l') return
|
|
287
|
+
if (f[0] === 'l') return j && leave(h, c, j)
|
|
267
288
|
if (f[0] !== 'm' || typeof f[2] !== 'number' || typeof f[3] !== 'string') return
|
|
268
289
|
if (!j) return send(c, ['a', f[1], f[2], { code: 'Q009', message: 'not joined' }])
|
|
269
290
|
const per = j.fn.def.limits?.perSecond
|
|
270
291
|
if (per && !take(j.bucket, per, per * 2)) {
|
|
271
|
-
|
|
272
|
-
return gone(h, c, 1008, 'rate limit')
|
|
292
|
+
return slow(h, c, `${per} messages a second to \`${j.fn.$id}\``)
|
|
273
293
|
}
|
|
274
|
-
j.chain = j.chain.then(() => run(h, c, j, f[2], f[3], f[4]))
|
|
294
|
+
j.chain = j.chain.then(() => run(h, c, j, { ack: f[2], name: f[3], msg: f[4] }))
|
|
275
295
|
}
|
|
276
296
|
|
|
277
297
|
function gone(h: Hub, c: Sock, code: number, reason?: string) {
|
|
@@ -284,9 +304,12 @@ function gone(h: Hub, c: Sock, code: number, reason?: string) {
|
|
|
284
304
|
|
|
285
305
|
/** The session again: `ctx`, then `authorize` for every join. A user who's gone closes the socket; 4401 tells the client. */
|
|
286
306
|
async function recheck(h: Hub, c: Sock) {
|
|
287
|
-
const ctx = await createScope(c.request, c.options)
|
|
307
|
+
const ctx = await createScope(c.request, c.options)
|
|
308
|
+
.ctx()
|
|
309
|
+
.catch(() => ({}) as Context)
|
|
288
310
|
if (!stateOf(h).socks.has(c)) return
|
|
289
|
-
if (c.user !== undefined && (ctx as { user?: { id?: string } }).user?.id !== c.user)
|
|
311
|
+
if (c.user !== undefined && (ctx as { user?: { id?: string } }).user?.id !== c.user)
|
|
312
|
+
return gone(h, c, 4401, 'session')
|
|
290
313
|
c.ctx = ctx
|
|
291
314
|
for (const j of Array.from(c.joins.values())) {
|
|
292
315
|
if (await allowed(j.fn, ctx, j.params).catch(() => false)) {
|
|
@@ -300,90 +323,78 @@ async function recheck(h: Hub, c: Sock) {
|
|
|
300
323
|
|
|
301
324
|
/** Open: the hello, the heartbeat watch and the session timer. */
|
|
302
325
|
/** `line`: one tab on a shared socket (mux.ts), which `limits.connections` counted already. */
|
|
303
|
-
export async function attach(
|
|
326
|
+
export async function attach(
|
|
327
|
+
peer: Peer,
|
|
328
|
+
request: Request,
|
|
329
|
+
scope: Scope,
|
|
330
|
+
h = hub(),
|
|
331
|
+
line = false,
|
|
332
|
+
): Promise<Attached> {
|
|
304
333
|
const st = stateOf(h)
|
|
305
334
|
const ctx = await scope.ctx()
|
|
306
335
|
const user = (ctx as { user?: { id?: string } }).user?.id
|
|
307
|
-
const c: Sock = {
|
|
308
|
-
|
|
336
|
+
const c: Sock = {
|
|
337
|
+
k: crypto.randomUUID(),
|
|
338
|
+
peer,
|
|
339
|
+
request,
|
|
340
|
+
options: scope.options,
|
|
341
|
+
ctx,
|
|
342
|
+
user,
|
|
343
|
+
joins: new Map(),
|
|
344
|
+
bucket: { tokens: h.limits.burst, at: Date.now() },
|
|
345
|
+
last: Date.now(),
|
|
346
|
+
timers: [],
|
|
347
|
+
}
|
|
348
|
+
if (
|
|
349
|
+
!line &&
|
|
350
|
+
user !== undefined &&
|
|
351
|
+
[...st.socks].filter((s) => s.user === user).length >= h.limits.connections
|
|
352
|
+
) {
|
|
309
353
|
peer.close(1008, 'too many sockets')
|
|
310
|
-
scope.options.onDiagnostic?.({
|
|
354
|
+
scope.options.onDiagnostic?.({
|
|
355
|
+
code: 'R009',
|
|
356
|
+
severity: 'error',
|
|
357
|
+
message: `R009: user \`${user}\` already has ${h.limits.connections} sockets`,
|
|
358
|
+
})
|
|
311
359
|
return { message() {}, close() {}, recheck: async () => {} }
|
|
312
360
|
}
|
|
313
361
|
st.socks.add(c)
|
|
314
362
|
send(c, ['h', { k: c.k, e: h.epoch, b: h.build ?? '' }])
|
|
315
363
|
let ticks = 0
|
|
316
364
|
const every = Math.max(1, Math.round(h.limits.session / h.limits.heartbeat))
|
|
317
|
-
c.timers.push(
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
365
|
+
c.timers.push(
|
|
366
|
+
unref(
|
|
367
|
+
setInterval(() => {
|
|
368
|
+
if (Date.now() - c.last > h.limits.heartbeat * 3) return gone(h, c, 1001, 'idle')
|
|
369
|
+
if (++ticks % every === 0) void recheck(h, c)
|
|
370
|
+
}, h.limits.heartbeat),
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
if (h.maxDuration)
|
|
374
|
+
c.timers.push(
|
|
375
|
+
unref(
|
|
376
|
+
setTimeout(() => gone(h, c, 1012, 'max duration'), Math.max(1, h.maxDuration - 10) * 1000),
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
return {
|
|
380
|
+
message: (text) => frame(h, c, text),
|
|
381
|
+
close: () => gone(h, c, 1000),
|
|
382
|
+
recheck: () => recheck(h, c),
|
|
334
383
|
}
|
|
335
|
-
// `v` first: the hub reads it off the front of the string, to leave the ring alone.
|
|
336
|
-
const raw = JSON.stringify({ ...(o.volatile && { v: 1 }), ...(o.from && { f: o.from }), ...(o.exceptSelf && { x: 1 }), ...(o.to && { t: o.to }), n: name, m: message })
|
|
337
|
-
if (raw.length > h.limits.message) throw new StartError('R008', `a \`${fn.$id}\` message is ${raw.length} bytes; a message may be ${h.limits.message}`, 'publish less, or an id subscribers fetch')
|
|
338
|
-
await h.bus.publish(roomName(fn.$id, valid), raw)
|
|
339
384
|
}
|
|
340
385
|
|
|
386
|
+
export { publish } from './socket/publish'
|
|
387
|
+
|
|
341
388
|
/** A shared value written by the server: `deckSlide.in(key).set(3)`. */
|
|
342
|
-
export
|
|
343
|
-
const h = hub()
|
|
344
|
-
const valid = await check(fn.def.key, key, `\`${fn.$id}\`'s key`)
|
|
345
|
-
const v = await check(fn.def.value, value, `\`${fn.$id}\`'s value`)
|
|
346
|
-
const st = stateOf(h), name = roomName(fn.$id, valid)
|
|
347
|
-
const stamp: Value = { v, at: Math.max(Date.now(), (st.values.get(name)?.at ?? 0) + 1), o: h.epoch }
|
|
348
|
-
store(st, name, stamp)
|
|
349
|
-
await h.bus.publish(name, JSON.stringify({ n: VALUE, m: stamp }))
|
|
350
|
-
}
|
|
389
|
+
export const setShared = sharedWriter(stateOf)
|
|
351
390
|
|
|
352
391
|
runtime.channels = { publish, set: setShared }
|
|
353
392
|
|
|
354
|
-
interface WsPeer { id: string; send(text: string): unknown; close(code?: number, reason?: string): unknown; websocket?: { bufferedAmount?: number } }
|
|
355
|
-
|
|
356
393
|
/** Nitro reads `crossws` off the response (research 32 §1). A refusal is a hook that throws, so no socket opens. */
|
|
357
|
-
export function socketRoute(
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') return Promise.resolve(new Response('Upgrade required', { status: 426, headers: { 'cache-control': 'no-store' } }))
|
|
365
|
-
try {
|
|
366
|
-
checkOrigins(origins)
|
|
367
|
-
} catch (e) {
|
|
368
|
-
return turnAway('R004', (e as Error).message, 500)
|
|
369
|
-
}
|
|
370
|
-
const origin = request.headers.get('origin')
|
|
371
|
-
if (!origin || (origin !== new URL(request.url).origin && !origins.includes(origin))) return turnAway('R010', `R010: origin \`${origin ?? 'none'}\` may not open a socket: list it in \`realtime({ origins })\``, 403)
|
|
372
|
-
const peers = new Map<string, Promise<Pick<Attached, 'message' | 'close'>>>()
|
|
373
|
-
const mux = new URL(request.url).searchParams.has('mux')
|
|
374
|
-
return hooks({
|
|
375
|
-
open(peer: WsPeer) {
|
|
376
|
-
const p: Peer = { send: t => void peer.send(t), close: (code, reason) => peer.close(code, reason), backlog: () => peer.websocket?.bufferedAmount ?? 0 }
|
|
377
|
-
peers.set(peer.id, (mux ? import('./mux').then(m => m.muxed(p, request, scope)) : attach(p, request, scope)).catch(e => {
|
|
378
|
-
scope.options.onDiagnostic?.({ code: 'E_RT', severity: 'error', message: `/_weft/ws: ${e instanceof Error ? e.stack ?? e.message : String(e)}` })
|
|
379
|
-
peer.close(1011, 'context failed')
|
|
380
|
-
return { message() {}, close() {} }
|
|
381
|
-
}))
|
|
382
|
-
},
|
|
383
|
-
message(peer: WsPeer, m: { text(): string }) { void peers.get(peer.id)?.then(a => a.message(m.text())) },
|
|
384
|
-
close(peer: WsPeer) {
|
|
385
|
-
void peers.get(peer.id)?.then(a => a.close())
|
|
386
|
-
peers.delete(peer.id)
|
|
387
|
-
},
|
|
388
|
-
})
|
|
394
|
+
export function socketRoute(
|
|
395
|
+
request: Request,
|
|
396
|
+
scope: Scope,
|
|
397
|
+
origins: string[] = [],
|
|
398
|
+
): Promise<Response> {
|
|
399
|
+
return socketRouteWith(request, scope, origins, attach)
|
|
389
400
|
}
|
package/src/stream.ts
CHANGED
|
@@ -10,4 +10,13 @@ export const stream = ((def: never) => {
|
|
|
10
10
|
return define(def)
|
|
11
11
|
}) as typeof define
|
|
12
12
|
|
|
13
|
-
export {
|
|
13
|
+
export {
|
|
14
|
+
backoff,
|
|
15
|
+
hold,
|
|
16
|
+
type Link,
|
|
17
|
+
type Stream,
|
|
18
|
+
type StreamDef,
|
|
19
|
+
type StreamFn,
|
|
20
|
+
type StreamOptions,
|
|
21
|
+
type StreamStatus,
|
|
22
|
+
} from './live'
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Broke, subscribe, type Hello } from './support'
|
|
2
|
+
|
|
3
|
+
export async function openApp(o: {
|
|
4
|
+
url: string
|
|
5
|
+
id: string
|
|
6
|
+
input: unknown
|
|
7
|
+
cookie: string | undefined
|
|
8
|
+
signal: AbortSignal
|
|
9
|
+
timeout: number
|
|
10
|
+
}) {
|
|
11
|
+
const { url, id, input, cookie, signal, timeout } = o
|
|
12
|
+
const a = await subscribe(url, id, input, cookie, signal)
|
|
13
|
+
const hi = await a.until((f) => f[0] === 'h', timeout)
|
|
14
|
+
if (!hi) throw new Broke('app', 'the app accepted the connection and said nothing')
|
|
15
|
+
const said = (hi[1] as Hello).l
|
|
16
|
+
if (said?.m !== 'feed' && said?.m !== 'external')
|
|
17
|
+
throw new Broke(
|
|
18
|
+
'app',
|
|
19
|
+
`the app's live mode is \`${said?.m ?? 'app'}\`, not a feed: its log says why`,
|
|
20
|
+
)
|
|
21
|
+
if (said?.d) throw new Broke('app', "the app's change feed is down")
|
|
22
|
+
const ack = await a.until(
|
|
23
|
+
(f) => (f[0] === 'a' || f[0] === 'e') && (f[1] as unknown[])[0] === 1,
|
|
24
|
+
timeout,
|
|
25
|
+
)
|
|
26
|
+
if (!ack) throw new Broke('app', 'no answer to the subscription')
|
|
27
|
+
const answer = (ack[1] as [number, unknown])[1]
|
|
28
|
+
const failed = answer as { code?: string; message?: string }
|
|
29
|
+
if (ack[0] === 'e')
|
|
30
|
+
throw new Broke(
|
|
31
|
+
'app',
|
|
32
|
+
failed.code === 'Q009'
|
|
33
|
+
? `the app doesn't know \`${id}\`: it runs another build`
|
|
34
|
+
: failed.code === 'A002'
|
|
35
|
+
? `\`${id}\` needs a signed-in user: set \`WEFT_STAGING_COOKIE\``
|
|
36
|
+
: `the subscription failed: ${failed.message}`,
|
|
37
|
+
)
|
|
38
|
+
return { ...a, tags: answer as string[] }
|
|
39
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type postgres from 'postgres'
|
|
2
|
+
import type { VerifyResult } from '../verify'
|
|
3
|
+
import { Broke, clean, type insert } from './support'
|
|
4
|
+
|
|
5
|
+
export async function cleanup(o: {
|
|
6
|
+
ac: AbortController
|
|
7
|
+
row: Awaited<ReturnType<typeof insert>> | undefined
|
|
8
|
+
ms: VerifyResult['ms']
|
|
9
|
+
sql: postgres.Sql | undefined
|
|
10
|
+
listener: postgres.Sql | undefined
|
|
11
|
+
marker: string
|
|
12
|
+
}) {
|
|
13
|
+
const { ac, row, ms, sql, listener, marker } = o
|
|
14
|
+
let cleaning: unknown
|
|
15
|
+
ac.abort()
|
|
16
|
+
if (row) {
|
|
17
|
+
const t = performance.now()
|
|
18
|
+
try {
|
|
19
|
+
await row.remove()
|
|
20
|
+
ms.cleanup = Math.round(performance.now() - t)
|
|
21
|
+
} catch (e) {
|
|
22
|
+
cleaning = e
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
await Promise.all([sql?.end({ timeout: 1 }), listener?.end({ timeout: 1 })]).catch(() => {})
|
|
26
|
+
if (cleaning) throw new Broke('cleanup', `row \`${marker}\` is still there: ${clean(cleaning)}`)
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import postgres from 'postgres'
|
|
2
|
+
|
|
3
|
+
export function database(db: string | undefined) {
|
|
4
|
+
const sql = db
|
|
5
|
+
? postgres(db, { max: 3, idle_timeout: 2, connect_timeout: 5, onnotice: () => {} })
|
|
6
|
+
: undefined
|
|
7
|
+
const listener = db
|
|
8
|
+
? postgres(db, { max: 1, idle_timeout: 2, connect_timeout: 5, onnotice: () => {} })
|
|
9
|
+
: undefined
|
|
10
|
+
return { sql, listener }
|
|
11
|
+
}
|