@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.
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 +38 -8
  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 +120 -492
  32. package/src/client.tsx +9 -2
  33. package/src/contract/describe.mjs +231 -0
  34. package/src/contract.mjs +144 -157
  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 +90 -166
  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 +112 -37
  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 -178
  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 -971
  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/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, type Fn, type StandardSchemaV1 } from './data'
12
- import { createScope, type Scope, type ScopeOptions } from './exec'
13
- import { checkOrigins, hub, type Channel, type Ev, type Hub } from './hub'
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(() => { for (const c of Array.from(made.socks)) gone(h, c, 1001) })
58
- h.watch(tags => {
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,>(t: T) => ((t as { unref?(): void } | null | undefined)?.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', VALUE = '$v', ASK = '$?'
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 }) => send(c, ['e', n, { code: e.code, message: e.message }])
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(h: Hub, c: Sock, n: number, id: string, params: unknown, cursor?: string) {
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') return refuse(c, n, new StartError('Q009', `no channel \`${id}\` (the page is older than the server): reload`))
148
- if (c.joins.size >= h.limits.subscriptions) return refuse(c, n, new StartError('R009', `this socket already has ${h.limits.subscriptions} joins`, 'join fewer rooms, or raise `limits.subscriptions`'))
149
- const j: Join = { n, fn, params, bucket: { tokens: h.limits.burst, at: Date.now() }, chain: Promise.resolve() }
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(fn.kind === 'shared' ? fn.def.key : fn.def.params, params, `\`${id}\`'s params`)
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, lost = 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 ? send(c, ['r', n, h.position()]) : void (lost = true)
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((a, b) => a.seq - b.seq)) deliver(c, j, h, ev)
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) => ({ params: j.params, publish: (n: string, m: unknown, o?: PublishOptions) => publish(j.fn, j.params, n, m, { ...o, from: c.k }) })
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) => { if (!j.gone) send(c, ['m', j.n, h.position(), name, msg]) }
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, () => j.fn.def.left!({ id: c.k, ctx: c.ctx, params: j.params, room: bound(c, j) }))
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 = (c: Sock, id: string, e: unknown): { code?: string | undefined; message: string; issues?: unknown } => {
217
- if (e instanceof StartError || e instanceof FnError) return { code: e.code, message: e.message, ...(e.issues && { issues: e.issues }) }
218
- c.options.onDiagnostic?.({ code: 'E_RT', severity: 'error', message: `${id}: ${e instanceof Error ? e.stack ?? e.message : String(e)}` })
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, name: string, msg: unknown) {
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), key = j.room!.name
231
- const stamp: Value = { v, at: Math.max(Date.now(), (st.values.get(key)?.at ?? 0) + 1), o: h.epoch }
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) throw new StartError('Q002', `\`${fn.$id}\` takes no message \`${name}\``, 'name one of `client`, with a handler in `on`')
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 = { ...createScope(c.request, c.options), ctx: () => Promise.resolve(c.ctx) }
240
- await enter(scope, () => handler({ msg: valid, id: c.k, ctx: c.ctx, params: j.params, room: bound(c, j), reply: reply(h, c, j) } as never))
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?.({ code: 'R008', severity: 'error', message: `R008: a channel frame of ${text.length} bytes; the limit is ${h.limits.body}` })
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 { f = JSON.parse(text) } catch { return gone(h, c, 1007, 'not JSON') }
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
- c.options.onDiagnostic?.({ code: 'R008', severity: 'error', message: `R008: a client sent over ${h.limits.perSecond} messages a second` })
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') return void join(h, c, f[1], f[2], f[3], typeof f[4] === 'string' ? f[4] : undefined)
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 (j && leave(h, c, j))
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
- c.options.onDiagnostic?.({ code: 'R008', severity: 'error', message: `R008: a client sent over ${per} messages a second to \`${j.fn.$id}\`` })
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).ctx().catch(() => ({}) as Context)
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) return gone(h, c, 4401, 'session')
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(peer: Peer, request: Request, scope: Scope, h = hub(), line = false): Promise<Attached> {
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 = { k: crypto.randomUUID(), peer, request, options: scope.options, ctx, user, joins: new Map(), bucket: { tokens: h.limits.burst, at: Date.now() }, last: Date.now(), timers: [] }
308
- if (!line && user !== undefined && [...st.socks].filter(s => s.user === user).length >= h.limits.connections) {
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?.({ code: 'R009', severity: 'error', message: `R009: user \`${user}\` already has ${h.limits.connections} sockets` })
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(unref(setInterval(() => {
318
- if (Date.now() - c.last > h.limits.heartbeat * 3) return gone(h, c, 1001, 'idle')
319
- if (++ticks % every === 0) void recheck(h, c)
320
- }, h.limits.heartbeat)))
321
- if (h.maxDuration) c.timers.push(unref(setTimeout(() => gone(h, c, 1012, 'max duration'), Math.max(1, h.maxDuration - 10) * 1000)))
322
- return { message: text => frame(h, c, text), close: () => gone(h, c, 1000), recheck: () => recheck(h, c) }
323
- }
324
-
325
- /** `room.publish` and `room.in(params).publish`. */
326
- export async function publish(fn: Fn, params: unknown, name: string, message: unknown, o: PublishOptions & { from?: string } = {}) {
327
- const h = hub()
328
- const valid = await check(fn.kind === 'shared' ? fn.def.key : fn.def.params, params, `\`${fn.$id}\`'s params`)
329
- if (h.dev) {
330
- const schema = Object.hasOwn(fn.def.server ?? {}, name) ? fn.def.server![name] : undefined
331
- if (!schema) throw new StartError('C002', `\`${fn.$id}\` has no server message \`${name}\``, 'add it to `server`', { status: 500 })
332
- const r = await schema['~standard'].validate(message)
333
- if (r.issues) throw new StartError('C002', `\`${fn.$id}.${name}\` was published with a message its schema rejects: ${r.issues.map(i => i.message).join('; ')}`, 'fix the message, or the schema', { status: 500 })
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 async function setShared(fn: Fn, key: unknown, value: unknown) {
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(request: Request, scope: Scope, origins: string[] = []): Promise<Response> {
358
- const hooks = (crossws: object) => Promise.resolve(Object.assign(new Response('Upgrade required', { status: 426 }), { crossws }))
359
- const turnAway = (code: string, message: string, status: number) => {
360
- scope.options.onDiagnostic?.({ code, severity: 'error', message })
361
- // oxlint-disable-next-line only-throw-error -- crossws answers an upgrade with the Response its hook throws
362
- return hooks({ upgrade() { throw new Response(message, { status }) } })
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 { backoff, hold, type Link, type Stream, type StreamDef, type StreamFn, type StreamOptions, type StreamStatus } from './live'
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
+ }