@weft-js/start 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/README.md +36 -3
  2. package/crate/Cargo.lock +1347 -0
  3. package/crate/Cargo.toml +72 -0
  4. package/crate/rust-toolchain.toml +3 -0
  5. package/crate/src/build/emit.rs +103 -0
  6. package/crate/src/build.rs +367 -0
  7. package/crate/src/ctx.rs +186 -0
  8. package/crate/src/envelope.rs +163 -0
  9. package/crate/src/functions.rs +173 -0
  10. package/crate/src/lib.rs +118 -0
  11. package/crate/src/native.rs +212 -0
  12. package/crate/src/otel.rs +62 -0
  13. package/crate/src/server.rs +203 -0
  14. package/package.json +46 -37
  15. package/src/app.mjs +51 -10
  16. package/src/asset-receipt.d.mts +13 -0
  17. package/src/asset-receipt.mjs +61 -0
  18. package/src/auth/better-auth.ts +215 -17
  19. package/src/auth/drizzle.ts +1 -1
  20. package/src/auth.ts +21 -6
  21. package/src/boot.ts +19 -8
  22. package/src/bus/postgres.ts +32 -19
  23. package/src/bus.ts +7 -4
  24. package/src/{capture.mjs → capture.ts} +34 -1
  25. package/src/changes-sql.d.mts +8 -2
  26. package/src/changes-sql.mjs +26 -10
  27. package/src/changes.ts +165 -66
  28. package/src/channel/connections.ts +216 -0
  29. package/src/channel/socket.ts +396 -0
  30. package/src/channel-fake.ts +19 -4
  31. package/src/channel.ts +124 -490
  32. package/src/client.tsx +9 -2
  33. package/src/contract/describe.mjs +231 -0
  34. package/src/contract.mjs +146 -155
  35. package/src/data/cache.ts +354 -0
  36. package/src/data/definitions.ts +94 -0
  37. package/src/data/draft.ts +104 -0
  38. package/src/data/foundation.ts +95 -0
  39. package/src/data/live-api.ts +22 -0
  40. package/src/data/mutation.ts +138 -0
  41. package/src/data/primitives.ts +135 -0
  42. package/src/data/query.ts +106 -0
  43. package/src/data/transport.ts +121 -0
  44. package/src/data/types.ts +303 -0
  45. package/src/data.ts +69 -894
  46. package/src/db/bun-sqlite.d.ts +4 -1
  47. package/src/db/changes-mysql.ts +61 -21
  48. package/src/db/changes-postgres.ts +197 -118
  49. package/src/db/changes-sqlite.ts +131 -64
  50. package/src/db/core.ts +38 -13
  51. package/src/db/index.ts +20 -4
  52. package/src/db/mysql.ts +22 -6
  53. package/src/db/postgres.ts +49 -13
  54. package/src/db/sqlite.ts +53 -27
  55. package/src/derive.d.mts +4 -1
  56. package/src/derive.mjs +136 -39
  57. package/src/dev-runtime.mjs +8 -4
  58. package/src/exec/errors.ts +57 -0
  59. package/src/exec/landing.ts +66 -0
  60. package/src/exec/scope.ts +174 -0
  61. package/src/exec/touch.ts +80 -0
  62. package/src/exec/validation.ts +176 -0
  63. package/src/exec.ts +191 -341
  64. package/src/extension.ts +8 -2
  65. package/src/first-paint.ts +26 -9
  66. package/src/form/value.ts +15 -0
  67. package/src/form.tsx +132 -43
  68. package/src/hub/channels.ts +102 -0
  69. package/src/hub/connections.ts +147 -0
  70. package/src/hub/delivery.ts +62 -0
  71. package/src/hub/feed.ts +168 -0
  72. package/src/hub/presence.ts +64 -0
  73. package/src/hub/route.ts +158 -0
  74. package/src/hub/subscriptions.ts +212 -0
  75. package/src/hub/topic.ts +98 -0
  76. package/src/hub/tracked.ts +12 -0
  77. package/src/hub/types.ts +190 -0
  78. package/src/hub/view.ts +147 -0
  79. package/src/hub.ts +132 -758
  80. package/src/index.ts +84 -10
  81. package/src/inline.mjs +356 -0
  82. package/src/inspect/compare.mjs +162 -0
  83. package/src/inspect/routes.mjs +154 -0
  84. package/src/inspect/samples.mjs +59 -0
  85. package/src/inspect/url.mjs +2 -0
  86. package/src/inspect.mjs +146 -187
  87. package/src/link/election.ts +178 -0
  88. package/src/link/lines.ts +27 -0
  89. package/src/link.ts +97 -168
  90. package/src/list-drizzle.ts +104 -18
  91. package/src/list.ts +127 -36
  92. package/src/listen.ts +32 -6
  93. package/src/live/api.ts +18 -0
  94. package/src/live/backoff.ts +6 -0
  95. package/src/live/frames.ts +42 -0
  96. package/src/live/read.ts +77 -0
  97. package/src/live/sub-types.ts +24 -0
  98. package/src/live/transport.ts +86 -0
  99. package/src/live/types.ts +52 -0
  100. package/src/live-fake.ts +20 -5
  101. package/src/live-target.mjs +113 -35
  102. package/src/live.ts +116 -225
  103. package/src/media.ts +8 -1
  104. package/src/migrations.mjs +14 -6
  105. package/src/mux.ts +161 -64
  106. package/src/og/DefaultCard.tsx +40 -9
  107. package/src/og/fonts.mjs +16 -8
  108. package/src/og/images.mjs +43 -22
  109. package/src/og/index.mjs +119 -43
  110. package/src/og/native.mjs +11 -4
  111. package/src/range.mjs +15 -3
  112. package/src/router.ts +56 -6
  113. package/src/routes.mjs +73 -22
  114. package/src/run/admission.ts +80 -0
  115. package/src/run/boot.tsx +115 -0
  116. package/src/run/cache-presentation.ts +67 -0
  117. package/src/run/cache.ts +254 -0
  118. package/src/run/helpers.ts +197 -0
  119. package/src/run/listeners.ts +155 -0
  120. package/src/run/navigation.ts +382 -0
  121. package/src/run/page-skeleton.tsx +35 -0
  122. package/src/run/prefetch.ts +128 -0
  123. package/src/run/retain-view.ts +18 -0
  124. package/src/run/shape-preparation.ts +97 -0
  125. package/src/run/shape-styles.ts +72 -0
  126. package/src/run/source-context/pin.ts +124 -0
  127. package/src/run/source-context.ts +311 -0
  128. package/src/run.tsx +50 -174
  129. package/src/rust/common.ts +163 -0
  130. package/src/rust/native.ts +282 -0
  131. package/src/rust.ts +103 -229
  132. package/src/server/card.tsx +65 -0
  133. package/src/server/compression.ts +115 -0
  134. package/src/server/core.ts +70 -0
  135. package/src/server/dispatch.ts +70 -0
  136. package/src/server/form.ts +85 -0
  137. package/src/server/functions.ts +129 -0
  138. package/src/server/handler.ts +39 -0
  139. package/src/server/head.ts +96 -0
  140. package/src/server/media.ts +60 -0
  141. package/src/server/navigation-cache.ts +80 -0
  142. package/src/server/navigation.tsx +85 -0
  143. package/src/server/page-cold.ts +95 -0
  144. package/src/server/page-stream.tsx +181 -0
  145. package/src/server/page-types.ts +51 -0
  146. package/src/server/page-warm.ts +130 -0
  147. package/src/server/page.ts +69 -0
  148. package/src/server/payload.ts +89 -0
  149. package/src/server/prerender.ts +60 -0
  150. package/src/server/preview.tsx +67 -0
  151. package/src/server/render.ts +167 -0
  152. package/src/server/request.ts +98 -0
  153. package/src/server/resolver.ts +228 -0
  154. package/src/server/routes.ts +150 -0
  155. package/src/server/services.ts +69 -0
  156. package/src/server/text.ts +29 -0
  157. package/src/server/types.ts +160 -0
  158. package/src/server.tsx +82 -964
  159. package/src/shape.mjs +30 -6
  160. package/src/share/display.ts +50 -0
  161. package/src/share/network.ts +119 -0
  162. package/src/share/receive.ts +70 -0
  163. package/src/share/state.ts +30 -0
  164. package/src/share/tab-queue.ts +28 -0
  165. package/src/share-server.ts +128 -43
  166. package/src/share.ts +103 -209
  167. package/src/sheets.mjs +30 -18
  168. package/src/sidecar/build.mjs +170 -0
  169. package/src/sidecar/cargo.mjs +164 -0
  170. package/src/sidecar/common.mjs +17 -0
  171. package/src/sidecar/dev-build.mjs +91 -0
  172. package/src/sidecar/dev-watch.mjs +88 -0
  173. package/src/sidecar/manifest.mjs +19 -0
  174. package/src/sidecar/process.mjs +58 -0
  175. package/src/sidecar/refresh.mjs +19 -0
  176. package/src/sidecar.mjs +75 -416
  177. package/src/socket/check.ts +50 -0
  178. package/src/socket/deliver.ts +16 -0
  179. package/src/socket/publish.ts +56 -0
  180. package/src/socket/rooms.ts +41 -0
  181. package/src/socket/route.ts +79 -0
  182. package/src/socket/shared.ts +26 -0
  183. package/src/socket/types.ts +48 -0
  184. package/src/socket.ts +205 -194
  185. package/src/stream.ts +10 -1
  186. package/src/verify/app.ts +39 -0
  187. package/src/verify/cleanup.ts +27 -0
  188. package/src/verify/database.ts +11 -0
  189. package/src/verify/live.ts +63 -0
  190. package/src/verify/notify.ts +35 -0
  191. package/src/verify/skip.ts +19 -0
  192. package/src/verify/support.ts +149 -0
  193. package/src/verify/timing.ts +15 -0
  194. package/src/verify/trigger.ts +14 -0
  195. package/src/verify.ts +77 -176
  196. package/src/view.tsx +96 -11
  197. package/src/vite/asset-bytes.mjs +95 -0
  198. package/src/vite/assets.mjs +117 -0
  199. package/src/vite/config-hooks.mjs +166 -0
  200. package/src/vite/dev-hooks.mjs +103 -0
  201. package/src/vite/dev.mjs +102 -0
  202. package/src/vite/entries.mjs +145 -0
  203. package/src/vite/module-hooks.mjs +288 -0
  204. package/src/vite/route-modules.mjs +289 -0
  205. package/src/vite/server-functions.mjs +222 -0
  206. package/src/vite/shape-entry-css.mjs +187 -0
  207. package/src/vite/shape-override.mjs +226 -0
  208. package/src/vite/shared.mjs +51 -0
  209. package/src/vite/sheets.mjs +92 -0
  210. package/src/vite/state.mjs +89 -0
  211. package/src/vite/syntax.mjs +139 -0
  212. package/src/vite.mjs +152 -853
  213. package/src/capture.d.mts +0 -1
@@ -0,0 +1,216 @@
1
+ import { Box } from '@weft-js/core/internal'
2
+ import {
3
+ DEV,
4
+ collect as boxes,
5
+ derived,
6
+ effect,
7
+ getOwner,
8
+ onCleanup,
9
+ state,
10
+ untrack,
11
+ type Derived,
12
+ type State,
13
+ } from '@weft-js/core'
14
+ import { currentScope, FnError, skip, StartError, track, type Fn } from '../data'
15
+ import type { ChannelStatus, ConnectOptions, Connection, SharedBox } from '../channel'
16
+ import {
17
+ acquire,
18
+ hold,
19
+ listOf,
20
+ post,
21
+ rejoin,
22
+ type Handle,
23
+ type Join,
24
+ type ListSpec,
25
+ } from './socket'
26
+
27
+ const NONE: never[] = []
28
+ type View = Join | undefined
29
+
30
+ /** The part `connect` and `shared` have in common: the current join, as the argument moves. */
31
+ function follow(
32
+ fn: Fn,
33
+ arg: unknown,
34
+ options: ConnectOptions | undefined,
35
+ handle: Handle,
36
+ joined?: (j: Join) => void,
37
+ ): State<View> {
38
+ const view = state<View>(undefined)
39
+ effect(
40
+ () => {
41
+ const params = typeof arg === 'function' ? (arg as () => unknown)() : arg
42
+ if (params === skip) return undefined
43
+ return untrack(() => {
44
+ const j = acquire(fn, params)
45
+ j.handles.add(handle)
46
+ joined?.(j)
47
+ const stop = hold(j, options?.keep)
48
+ onCleanup(() => {
49
+ j.handles.delete(handle)
50
+ stop()
51
+ })
52
+ return j
53
+ })
54
+ },
55
+ (now) => {
56
+ view.value = now
57
+ },
58
+ )
59
+ return view
60
+ }
61
+
62
+ function owned(fn: Fn) {
63
+ if (!getOwner())
64
+ throw new StartError(
65
+ 'W051',
66
+ DEV
67
+ ? `\`${fn.$id}.connect()\` was called outside a component or scope: nothing would close it`
68
+ : fn.$id,
69
+ DEV
70
+ ? 'call it in a component, a `defineState` setup or `createRoot(dispose => …)`'
71
+ : undefined,
72
+ )
73
+ }
74
+
75
+ /** `@weft-js/start/share`'s way in: a join that nothing on the page declared, so a tab shares this socket and its lifecycle. */
76
+ export const connectBuiltIn = ($id: string) =>
77
+ connect({ kind: 'channel', $id, def: {} }, undefined, { keep: 0 })
78
+
79
+ export function connect(
80
+ fn: Fn,
81
+ arg: unknown,
82
+ options?: ConnectOptions,
83
+ ): Connection<Record<string, never>, Record<string, never>> {
84
+ owned(fn)
85
+ if (currentScope()) {
86
+ const closed = { value: 'connecting' as ChannelStatus }
87
+ return {
88
+ status: closed,
89
+ room: { value: undefined },
90
+ error: undefined,
91
+ resets: { value: 0 },
92
+ collect: () => ({ value: NONE }),
93
+ on: () => () => {},
94
+ onReset: () => () => {},
95
+ send: () =>
96
+ Promise.reject(new FnError({ message: 'a channel has no socket on the server' }, 500)),
97
+ retry() {},
98
+ }
99
+ }
100
+ const handle: Handle = { on: new Map(), resets: new Set() }
101
+ const wanted: ListSpec[] = []
102
+ const view = follow(fn, arg, options, handle, (j) => {
103
+ for (const w of wanted) listOf(j, w)
104
+ })
105
+ return {
106
+ status: derived(() => view.value?.status.value ?? 'closed'),
107
+ room: derived(() => view.value?.key),
108
+ get error() {
109
+ return view.value?.error.value
110
+ },
111
+ resets: derived(() => view.value?.resets.value ?? 0),
112
+ collect(name, { max = 200, key } = {}) {
113
+ const spec: ListSpec = { name, max, key }
114
+ wanted.push(spec)
115
+ const had = untrack(() => view.value)
116
+ if (had) listOf(had, spec)
117
+ return derived(() => {
118
+ const j = view.value
119
+ return (j ? listOf(j, spec).box.value : NONE) as never[]
120
+ })
121
+ },
122
+ on(name, listener) {
123
+ const set = handle.on.get(name) ?? new Set()
124
+ handle.on.set(name, set)
125
+ set.add(listener)
126
+ const stop = () => void set.delete(listener)
127
+ onCleanup(stop)
128
+ return stop
129
+ },
130
+ onReset(listener) {
131
+ handle.resets.add(listener)
132
+ const stop = () => void handle.resets.delete(listener)
133
+ onCleanup(stop)
134
+ return stop
135
+ },
136
+ send(name, message) {
137
+ const j = untrack(() => view.value)
138
+ return j
139
+ ? post(j, name, message)
140
+ : Promise.reject(new FnError({ message: 'this channel is closed' }, 400))
141
+ },
142
+ retry() {
143
+ const j = untrack(() => view.value)
144
+ if (j?.refused) rejoin(j)
145
+ },
146
+ }
147
+ }
148
+
149
+ /** `Box.w` is the one way a write gets in, so a subclass sees every write, and `Instance` never touches it. */
150
+ class Shared<T> extends Box<T> {
151
+ declare remote?: boolean
152
+ declare pending: number
153
+ declare base: { v: T; at: number }
154
+ declare view: State<View>
155
+ declare statusOf: Derived<ChannelStatus>
156
+ declare problem: State<unknown>
157
+ get status() {
158
+ return this.statusOf
159
+ }
160
+ get error() {
161
+ return this.problem.value
162
+ }
163
+ /** A server value: applied unless a local write is still on its way. */
164
+ apply(v: { v: T; at: number }) {
165
+ if (v.at >= this.base.at) this.base = v
166
+ if (!this.pending) this.quiet(this.base.v)
167
+ }
168
+ quiet(v: T) {
169
+ this.remote = true
170
+ this.w(v)
171
+ this.remote = false
172
+ }
173
+ override w(v: T) {
174
+ if (this.remote) return super.w(v)
175
+ const j = untrack(() => this.view.value)
176
+ if (!j) return super.w(v)
177
+ super.w(v)
178
+ this.pending++
179
+ this.problem.value = undefined
180
+ void track(
181
+ post(j, '$set', v)
182
+ .then(
183
+ () => {
184
+ this.pending--
185
+ },
186
+ (e) => {
187
+ this.pending--
188
+ this.problem.value = e
189
+ },
190
+ )
191
+ .finally(() => {
192
+ if (!this.pending) this.quiet(this.base.v)
193
+ }),
194
+ )
195
+ }
196
+ }
197
+
198
+ export function shared(fn: Fn, arg: unknown, options?: ConnectOptions): SharedBox<unknown> {
199
+ owned(fn)
200
+ const initial = fn.def.initial
201
+ const [b] = boxes(() => new Shared<unknown>(initial))
202
+ b.pending = 0
203
+ b.base = { v: initial, at: 0 }
204
+ b.problem = state<unknown>(undefined)
205
+ if (currentScope()) {
206
+ b.statusOf = { value: 'connecting' }
207
+ b.view = state<View>(undefined)
208
+ return b
209
+ }
210
+ const handle: Handle = { on: new Map(), resets: new Set(), value: (v) => b.apply(v) }
211
+ b.view = follow(fn, arg, options, handle, () => {
212
+ b.base = { v: initial, at: 0 }
213
+ })
214
+ b.statusOf = derived(() => b.view.value?.status.value ?? 'closed')
215
+ return b
216
+ }
@@ -0,0 +1,396 @@
1
+ import { DEV, batch, createRoot, state, untrack, type State } from '@weft-js/core'
2
+ import { HANDOVER, SOCKETS, openLine, socketDown } from '../link'
3
+ import { FnError, keyOf, net, rt, track, type Fn } from '../data'
4
+ import type { ChannelStatus } from '../channel'
5
+
6
+ // ---- joins ----------------------------------------------------------------------------------------------------------
7
+
8
+ export interface ListSpec {
9
+ name: string
10
+ max: number
11
+ key?: ((m: never) => unknown) | undefined
12
+ }
13
+ export interface List extends ListSpec {
14
+ box: State<unknown[]>
15
+ }
16
+ export interface Handle {
17
+ on: Map<string, Set<(m: never) => void>>
18
+ resets: Set<() => void>
19
+ value?(v: { v: unknown; at: number }): void
20
+ }
21
+ export interface Join {
22
+ n: number
23
+ key: string
24
+ fn: Fn
25
+ params: unknown
26
+ refs: number
27
+ timer?: ReturnType<typeof setTimeout> | undefined
28
+ cursor?: string | undefined
29
+ joined: boolean
30
+ refused: boolean
31
+ status: State<ChannelStatus>
32
+ error: State<unknown>
33
+ resets: State<number>
34
+ lists: Map<string, List>
35
+ handles: Set<Handle>
36
+ /** `send`s waiting for the join. */
37
+ waiting: Set<() => void>
38
+ }
39
+
40
+ export const joins = new Map<string, Join>()
41
+ export const byN = new Map<number, Join>()
42
+ let nth = 0
43
+
44
+ const KEEP = 30_000
45
+ const ms = (keep: string | number = KEEP): number => {
46
+ if (typeof keep === 'number') return keep
47
+ const m = /^(\d+)(ms|s|m)$/.exec(keep)
48
+ return m ? +m[1]! * { ms: 1, s: 1000, m: 60_000 }[m[2] as 'ms' | 's' | 'm'] : Infinity
49
+ }
50
+
51
+ export function acquire(fn: Fn, params: unknown): Join {
52
+ const key = keyOf(fn.$id, params)
53
+ let j = joins.get(key)
54
+ if (!j) {
55
+ j = createRoot(
56
+ () => ({
57
+ n: ++nth,
58
+ key,
59
+ fn,
60
+ params,
61
+ refs: 0,
62
+ joined: false,
63
+ refused: false,
64
+ status: state<ChannelStatus>('connecting'),
65
+ error: state<unknown>(undefined),
66
+ resets: state(0),
67
+ lists: new Map(),
68
+ handles: new Set(),
69
+ waiting: new Set(),
70
+ }),
71
+ null,
72
+ )
73
+ if (DEV && net() && !net()!.resets.includes(reset)) net()!.resets.push(reset)
74
+ joins.set(key, j)
75
+ byN.set(j.n, j)
76
+ if (live) enter(j)
77
+ else start()
78
+ }
79
+ clearTimeout(j.timer)
80
+ j.timer = undefined
81
+ j.refs++
82
+ return j
83
+ }
84
+
85
+ function release(j: Join, keep?: string | number) {
86
+ if (--j.refs) return
87
+ const t = ms(keep)
88
+ if (t === Infinity) return
89
+ if (t) j.timer = setTimeout(() => drop(j), t)
90
+ else drop(j)
91
+ }
92
+
93
+ function drop(j: Join) {
94
+ joins.delete(j.key)
95
+ byN.delete(j.n)
96
+ j.status.value = 'closed'
97
+ if (live) send(['l', j.n])
98
+ for (const w of Array.from(j.waiting)) w()
99
+ if (!joins.size) live?.close()
100
+ }
101
+
102
+ export function hold(j: Join, keep?: string | number): () => void {
103
+ const token = {}
104
+ const stop = () => {
105
+ net()?.open.delete(token)
106
+ release(j, keep)
107
+ }
108
+ if (DEV) net()?.open.set(token, [`channel \`${j.key}\``, stop])
109
+ return stop
110
+ }
111
+
112
+ export function listOf(j: Join, spec: ListSpec): List {
113
+ const id = `${spec.name}:${spec.max}:${spec.key ? String(spec.key) : ''}`
114
+ let l = j.lists.get(id)
115
+ if (!l) j.lists.set(id, (l = createRoot(() => ({ ...spec, box: state<unknown[]>([]) }), null)))
116
+ return l
117
+ }
118
+
119
+ // ---- sends ----------------------------------------------------------------------------------------------------------
120
+
121
+ const ACK_WAIT = 10_000
122
+ const acks = new Map<number, { ok: () => void; no: (e: unknown) => void; n: number }>()
123
+ let ackId = 0
124
+
125
+ export async function post(j: Join, name: string, message: unknown): Promise<void> {
126
+ if (!SOCKETS && !(DEV && net()))
127
+ throw new FnError({ code: 'R024', message: "this deploy can't hold a socket: not sent" }, 503)
128
+ if (!j.joined && !j.refused) {
129
+ await new Promise<void>((ok) => {
130
+ const done = () => {
131
+ clearTimeout(t)
132
+ j.waiting.delete(done)
133
+ ok()
134
+ }
135
+ const t = setTimeout(done, ACK_WAIT)
136
+ j.waiting.add(done)
137
+ })
138
+ }
139
+ if (!j.joined)
140
+ throw new FnError(
141
+ {
142
+ ...(j.refused && { code: 'R011' }),
143
+ message: j.refused ? 'not allowed in this channel' : 'no connection: not sent',
144
+ },
145
+ j.refused ? 403 : 503,
146
+ )
147
+ const ack = ++ackId
148
+ const p = new Promise<void>((ok, no) => {
149
+ acks.set(ack, { ok, no, n: j.n })
150
+ })
151
+ send(['m', j.n, ack, name, message])
152
+ return track(p)
153
+ }
154
+
155
+ // ---- the socket ------------------------------------------------------------------------------------------------------
156
+
157
+ /** What the server sends a channel's client: the first element says which. */
158
+ export type Frame =
159
+ | ['h', { b?: string }]
160
+ | ['j', number, string | undefined]
161
+ | ['m', number, string | undefined, string, never]
162
+ | ['r', number, string | undefined]
163
+ | ['e', number, { code?: string; message?: string }]
164
+ | ['a', number, number, { code?: string; message?: string } | undefined]
165
+
166
+ /** One end of a socket, as a channel holds it. */
167
+ export interface SocketLink {
168
+ send(text: string): void
169
+ close(): void
170
+ }
171
+
172
+ /** This tab's line on the browser's one socket (link.ts). `done` gets the close code. */
173
+ function browser(onFrame: (d: Frame) => void, closed: (code: number) => void): SocketLink {
174
+ return openLine('c', '', (text) => onFrame(JSON.parse(text)), closed)
175
+ }
176
+
177
+ let fake: (onFrame: (d: Frame) => void, closed: (code: number) => void) => SocketLink
178
+ /** Keep the test transport resolved from the original entry. */
179
+ export function setFake(value: typeof fake) {
180
+ fake = value
181
+ }
182
+
183
+ const BASE = 500,
184
+ CAP = 30_000,
185
+ PING = 20_000,
186
+ DEAD = 45_000,
187
+ HIDDEN = 300_000
188
+
189
+ let live: SocketLink | undefined
190
+ let open = false
191
+ let running = false
192
+ let attempt = 0
193
+ let build: string | undefined
194
+ let nudge: (() => void) | undefined
195
+ let parked = false
196
+ let hiddenAt: ReturnType<typeof setTimeout> | undefined
197
+
198
+ /** A leader tab can be hidden while a sibling is looking: `@weft-js/start/share` says when this socket must stay. */
199
+ export const keepOpen: { stay?: (() => boolean) | undefined } = {}
200
+
201
+ const send = (f: unknown[]) => live?.send(JSON.stringify(f))
202
+ const statusAll = (s: ChannelStatus) => {
203
+ for (const j of joins.values()) if (!j.refused) j.status.value = s
204
+ }
205
+
206
+ function enter(j: Join) {
207
+ if (!open || !live) return
208
+ send(['j', j.n, j.fn.$id, j.params, j.cursor])
209
+ }
210
+
211
+ export function rejoin(j: Join) {
212
+ j.refused = false
213
+ j.status.value = 'connecting'
214
+ if (running) enter(j)
215
+ else start()
216
+ }
217
+
218
+ function start() {
219
+ if (running) return
220
+ // a function host: no socket to dial, so say so once instead of retrying forever
221
+ if (!SOCKETS && !(DEV && net())) return statusAll('unavailable')
222
+ running = true
223
+ queueMicrotask(() => void run())
224
+ }
225
+
226
+ function dispatch(j: Join, name: string, msg: never) {
227
+ if (name === '$v') {
228
+ for (const h of j.handles) h.value?.(msg)
229
+ return
230
+ }
231
+ for (const l of j.lists.values()) {
232
+ if (l.name !== name) continue
233
+ const was = untrack(() => l.box.value)
234
+ const key = l.key?.(msg)
235
+ const at = l.key ? was.findIndex((m) => l.key!(m as never) === key) : -1
236
+ const next = at >= 0 ? was.map((m, i) => (i === at ? msg : m)) : [...was, msg]
237
+ l.box.value = next.length > l.max ? next.slice(-l.max) : next
238
+ }
239
+ for (const h of j.handles) for (const f of h.on.get(name) ?? []) f(msg)
240
+ }
241
+
242
+ function frame(d: Frame) {
243
+ batch(() => {
244
+ const j = typeof d[1] === 'number' ? byN.get(d[1]) : undefined
245
+ if (d[0] === 'h') {
246
+ if (build && d[1].b && d[1].b !== build) rt.stale.value = true
247
+ build = d[1].b
248
+ open = true
249
+ attempt = 0
250
+ for (const x of joins.values()) if (!x.refused) enter(x)
251
+ } else if (d[0] === 'j' && j) {
252
+ j.cursor ??= d[2]
253
+ j.joined = true
254
+ j.error.value = undefined
255
+ j.status.value = 'open'
256
+ for (const w of Array.from(j.waiting)) w()
257
+ } else if (d[0] === 'm' && j) {
258
+ j.cursor = d[2]
259
+ dispatch(j, d[3], d[4])
260
+ } else if (d[0] === 'r' && j) {
261
+ j.cursor = d[2]
262
+ for (const l of j.lists.values()) l.box.value = []
263
+ j.resets.value++
264
+ for (const h of j.handles) for (const f of h.resets) f()
265
+ } else if (d[0] === 'e' && j) {
266
+ j.joined = false
267
+ j.refused = true
268
+ j.error.value = new FnError(d[2], d[2].code === 'R011' ? 403 : 400)
269
+ j.status.value = d[2].code === 'R011' ? 'unauthorized' : 'closed'
270
+ for (const w of Array.from(j.waiting)) w()
271
+ } else if (d[0] === 'a') {
272
+ const p = acks.get(d[2])
273
+ acks.delete(d[2])
274
+ if (d[3]) p?.no(new FnError(d[3], d[3].code === 'R011' ? 403 : 400))
275
+ else p?.ok()
276
+ }
277
+ })
278
+ }
279
+
280
+ const sleep = (t: number) =>
281
+ new Promise<void>((ok) => {
282
+ const timer = setTimeout(ok, t)
283
+ nudge = () => {
284
+ clearTimeout(timer)
285
+ nudge = undefined
286
+ ok()
287
+ }
288
+ })
289
+
290
+ async function run() {
291
+ listen(true)
292
+ const fakeNet = () => (DEV ? net() : undefined)
293
+ while (joins.size) {
294
+ if (parked || !navigator.onLine) {
295
+ statusAll('reconnecting')
296
+ await sleep(CAP)
297
+ continue
298
+ }
299
+ let code = 0
300
+ let dead: ReturnType<typeof setTimeout> | undefined
301
+ let ping: ReturnType<typeof setInterval> | undefined
302
+ const alive = () => {
303
+ clearTimeout(dead)
304
+ if (!fakeNet()) dead = setTimeout(() => live?.close(), DEAD)
305
+ }
306
+ await new Promise<void>((done) => {
307
+ const take = (d: Frame) => {
308
+ alive()
309
+ frame(d)
310
+ }
311
+ live = (fakeNet() ? fake : browser)(take, (c) => {
312
+ code = c
313
+ done()
314
+ })
315
+ alive()
316
+ if (!fakeNet()) ping = setInterval(() => send(['p']), PING)
317
+ })
318
+ clearTimeout(dead)
319
+ clearInterval(ping)
320
+ live = undefined
321
+ open = false
322
+ for (const j of joins.values()) j.joined = false
323
+ for (const [id, p] of acks) {
324
+ acks.delete(id)
325
+ p.no(
326
+ new FnError({ message: 'the connection dropped: this may or may not have been sent' }, 503),
327
+ )
328
+ }
329
+ if (!joins.size) break
330
+ const n = fakeNet()
331
+ if (n) n.reconnects++
332
+ if (code === 4401) for (const j of joins.values()) j.status.value = 'unauthorized'
333
+ else statusAll('reconnecting')
334
+ if (!n) await sleep(code === HANDOVER ? 0 : backoff())
335
+ }
336
+ running = false
337
+ attempt = 0
338
+ listen(false)
339
+ }
340
+
341
+ /** Full jitter, 0.5 s doubling to 30 s (research 30 §6). */
342
+ const backoff = () => Math.random() * Math.min(CAP, BASE * 2 ** attempt++)
343
+
344
+ // ---- the page -------------------------------------------------------------------------------------------------------
345
+
346
+ const wake = () => {
347
+ parked = false
348
+ nudge?.()
349
+ }
350
+ function park() {
351
+ parked = true
352
+ live?.close()
353
+ }
354
+ const parkHidden = () => {
355
+ if (keepOpen.stay?.()) hiddenAt = setTimeout(parkHidden, HIDDEN)
356
+ else park()
357
+ }
358
+ function onVisibility() {
359
+ clearTimeout(hiddenAt)
360
+ if (document.visibilityState === 'hidden') hiddenAt = setTimeout(parkHidden, HIDDEN)
361
+ else wake()
362
+ }
363
+ /** A sibling came back while this tab's socket was parked. */
364
+ export const wakeSocket = () => wake()
365
+ const onOffline = () => {
366
+ socketDown()
367
+ live?.close()
368
+ }
369
+
370
+ function listen(on: boolean) {
371
+ if (typeof window === 'undefined') return
372
+ const m = on ? 'addEventListener' : 'removeEventListener'
373
+ window[m]('online', wake)
374
+ window[m]('offline', onOffline)
375
+ window[m]('pagehide', park)
376
+ window[m]('pageshow', wake)
377
+ document[m]('visibilitychange', onVisibility)
378
+ if (!on) clearTimeout(hiddenAt)
379
+ }
380
+
381
+ /** @weft-js/core/test, after each test. */
382
+ function reset() {
383
+ for (const j of joins.values()) clearTimeout(j.timer)
384
+ joins.clear()
385
+ byN.clear()
386
+ acks.clear()
387
+ live?.close()
388
+ nudge?.()
389
+ build = undefined
390
+ parked = false
391
+ attempt = 0
392
+ net()!.reconnects = 0
393
+ untrack(() => {
394
+ rt.stale.value = false
395
+ })
396
+ }
@@ -6,9 +6,17 @@ import type { Frame, SocketLink } from './channel'
6
6
  /** A socket to the real server code in this process. */
7
7
  const exec_ = async () => (runtime.exec ??= await import('./exec'))
8
8
 
9
- export async function open(frame: (d: Frame) => void, done: (code: number) => void, up: () => void): Promise<SocketLink> {
9
+ export async function open(
10
+ frame: (d: Frame) => void,
11
+ done: (code: number) => void,
12
+ up: () => void,
13
+ ): Promise<SocketLink> {
10
14
  const n = net()!
11
- const [{ hub, resetHub }, { attach, publish }, exec] = await Promise.all([import('./hub'), import('./socket'), exec_()])
15
+ const [{ hub, resetHub }, { attach, publish }, exec] = await Promise.all([
16
+ import('./hub'),
17
+ import('./socket'),
18
+ exec_(),
19
+ ])
12
20
  if (!n.online()) throw new Error('offline')
13
21
  if (!n.resets.includes(resetHub)) n.resets.push(resetHub)
14
22
  const h = hub()
@@ -36,7 +44,10 @@ export async function open(frame: (d: Frame) => void, done: (code: number) => vo
36
44
  const socket = await attach(peer, request, fakeScope(exec, n, request), h)
37
45
  n.ws = {
38
46
  publish: (id, params, name, msg) => publish(fns.get(id)!, params, name, msg),
39
- drop: () => { peer.close(1006); socket.close() }, // both ends, as a real cut: the server runs `left`
47
+ drop: () => {
48
+ peer.close(1006)
49
+ socket.close()
50
+ }, // both ends, as a real cut: the server runs `left`
40
51
  recheck: () => socket.recheck(),
41
52
  }
42
53
  up()
@@ -46,7 +57,11 @@ export async function open(frame: (d: Frame) => void, done: (code: number) => vo
46
57
  if (f[0] === 'm') n.sent.push({ id: rooms.get(f[1]) ?? '', name: f[3], msg: f[4] })
47
58
  if (f[0] === 'j') {
48
59
  rooms.set(f[1], f[2])
49
- void track(new Promise<void>(ok => { joining.set(f[1], ok) })) // `net.settle()` waits for the join's answer
60
+ void track(
61
+ new Promise<void>((ok) => {
62
+ joining.set(f[1], ok)
63
+ }),
64
+ ) // `net.settle()` waits for the join's answer
50
65
  }
51
66
  socket.message(text)
52
67
  },