@weft-js/start 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/README.md +36 -3
  2. package/crate/Cargo.lock +1347 -0
  3. package/crate/Cargo.toml +72 -0
  4. package/crate/rust-toolchain.toml +3 -0
  5. package/crate/src/build/emit.rs +103 -0
  6. package/crate/src/build.rs +367 -0
  7. package/crate/src/ctx.rs +186 -0
  8. package/crate/src/envelope.rs +163 -0
  9. package/crate/src/functions.rs +173 -0
  10. package/crate/src/lib.rs +118 -0
  11. package/crate/src/native.rs +212 -0
  12. package/crate/src/otel.rs +62 -0
  13. package/crate/src/server.rs +203 -0
  14. package/package.json +46 -37
  15. package/src/app.mjs +51 -10
  16. package/src/asset-receipt.d.mts +13 -0
  17. package/src/asset-receipt.mjs +61 -0
  18. package/src/auth/better-auth.ts +215 -17
  19. package/src/auth/drizzle.ts +1 -1
  20. package/src/auth.ts +21 -6
  21. package/src/boot.ts +19 -8
  22. package/src/bus/postgres.ts +32 -19
  23. package/src/bus.ts +7 -4
  24. package/src/{capture.mjs → capture.ts} +34 -1
  25. package/src/changes-sql.d.mts +8 -2
  26. package/src/changes-sql.mjs +26 -10
  27. package/src/changes.ts +165 -66
  28. package/src/channel/connections.ts +216 -0
  29. package/src/channel/socket.ts +396 -0
  30. package/src/channel-fake.ts +19 -4
  31. package/src/channel.ts +124 -490
  32. package/src/client.tsx +9 -2
  33. package/src/contract/describe.mjs +231 -0
  34. package/src/contract.mjs +146 -155
  35. package/src/data/cache.ts +354 -0
  36. package/src/data/definitions.ts +94 -0
  37. package/src/data/draft.ts +104 -0
  38. package/src/data/foundation.ts +95 -0
  39. package/src/data/live-api.ts +22 -0
  40. package/src/data/mutation.ts +138 -0
  41. package/src/data/primitives.ts +135 -0
  42. package/src/data/query.ts +106 -0
  43. package/src/data/transport.ts +121 -0
  44. package/src/data/types.ts +303 -0
  45. package/src/data.ts +69 -894
  46. package/src/db/bun-sqlite.d.ts +4 -1
  47. package/src/db/changes-mysql.ts +61 -21
  48. package/src/db/changes-postgres.ts +197 -118
  49. package/src/db/changes-sqlite.ts +131 -64
  50. package/src/db/core.ts +38 -13
  51. package/src/db/index.ts +20 -4
  52. package/src/db/mysql.ts +22 -6
  53. package/src/db/postgres.ts +49 -13
  54. package/src/db/sqlite.ts +53 -27
  55. package/src/derive.d.mts +4 -1
  56. package/src/derive.mjs +136 -39
  57. package/src/dev-runtime.mjs +8 -4
  58. package/src/exec/errors.ts +57 -0
  59. package/src/exec/landing.ts +66 -0
  60. package/src/exec/scope.ts +174 -0
  61. package/src/exec/touch.ts +80 -0
  62. package/src/exec/validation.ts +176 -0
  63. package/src/exec.ts +191 -341
  64. package/src/extension.ts +8 -2
  65. package/src/first-paint.ts +26 -9
  66. package/src/form/value.ts +15 -0
  67. package/src/form.tsx +132 -43
  68. package/src/hub/channels.ts +102 -0
  69. package/src/hub/connections.ts +147 -0
  70. package/src/hub/delivery.ts +62 -0
  71. package/src/hub/feed.ts +168 -0
  72. package/src/hub/presence.ts +64 -0
  73. package/src/hub/route.ts +158 -0
  74. package/src/hub/subscriptions.ts +212 -0
  75. package/src/hub/topic.ts +98 -0
  76. package/src/hub/tracked.ts +12 -0
  77. package/src/hub/types.ts +190 -0
  78. package/src/hub/view.ts +147 -0
  79. package/src/hub.ts +132 -758
  80. package/src/index.ts +84 -10
  81. package/src/inline.mjs +356 -0
  82. package/src/inspect/compare.mjs +162 -0
  83. package/src/inspect/routes.mjs +154 -0
  84. package/src/inspect/samples.mjs +59 -0
  85. package/src/inspect/url.mjs +2 -0
  86. package/src/inspect.mjs +146 -187
  87. package/src/link/election.ts +178 -0
  88. package/src/link/lines.ts +27 -0
  89. package/src/link.ts +97 -168
  90. package/src/list-drizzle.ts +104 -18
  91. package/src/list.ts +127 -36
  92. package/src/listen.ts +32 -6
  93. package/src/live/api.ts +18 -0
  94. package/src/live/backoff.ts +6 -0
  95. package/src/live/frames.ts +42 -0
  96. package/src/live/read.ts +77 -0
  97. package/src/live/sub-types.ts +24 -0
  98. package/src/live/transport.ts +86 -0
  99. package/src/live/types.ts +52 -0
  100. package/src/live-fake.ts +20 -5
  101. package/src/live-target.mjs +113 -35
  102. package/src/live.ts +116 -225
  103. package/src/media.ts +8 -1
  104. package/src/migrations.mjs +14 -6
  105. package/src/mux.ts +161 -64
  106. package/src/og/DefaultCard.tsx +40 -9
  107. package/src/og/fonts.mjs +16 -8
  108. package/src/og/images.mjs +43 -22
  109. package/src/og/index.mjs +119 -43
  110. package/src/og/native.mjs +11 -4
  111. package/src/range.mjs +15 -3
  112. package/src/router.ts +56 -6
  113. package/src/routes.mjs +73 -22
  114. package/src/run/admission.ts +80 -0
  115. package/src/run/boot.tsx +115 -0
  116. package/src/run/cache-presentation.ts +67 -0
  117. package/src/run/cache.ts +254 -0
  118. package/src/run/helpers.ts +197 -0
  119. package/src/run/listeners.ts +155 -0
  120. package/src/run/navigation.ts +382 -0
  121. package/src/run/page-skeleton.tsx +35 -0
  122. package/src/run/prefetch.ts +128 -0
  123. package/src/run/retain-view.ts +18 -0
  124. package/src/run/shape-preparation.ts +97 -0
  125. package/src/run/shape-styles.ts +72 -0
  126. package/src/run/source-context/pin.ts +124 -0
  127. package/src/run/source-context.ts +311 -0
  128. package/src/run.tsx +50 -174
  129. package/src/rust/common.ts +163 -0
  130. package/src/rust/native.ts +282 -0
  131. package/src/rust.ts +103 -229
  132. package/src/server/card.tsx +65 -0
  133. package/src/server/compression.ts +115 -0
  134. package/src/server/core.ts +70 -0
  135. package/src/server/dispatch.ts +70 -0
  136. package/src/server/form.ts +85 -0
  137. package/src/server/functions.ts +129 -0
  138. package/src/server/handler.ts +39 -0
  139. package/src/server/head.ts +96 -0
  140. package/src/server/media.ts +60 -0
  141. package/src/server/navigation-cache.ts +80 -0
  142. package/src/server/navigation.tsx +85 -0
  143. package/src/server/page-cold.ts +95 -0
  144. package/src/server/page-stream.tsx +181 -0
  145. package/src/server/page-types.ts +51 -0
  146. package/src/server/page-warm.ts +130 -0
  147. package/src/server/page.ts +69 -0
  148. package/src/server/payload.ts +89 -0
  149. package/src/server/prerender.ts +60 -0
  150. package/src/server/preview.tsx +67 -0
  151. package/src/server/render.ts +167 -0
  152. package/src/server/request.ts +98 -0
  153. package/src/server/resolver.ts +228 -0
  154. package/src/server/routes.ts +150 -0
  155. package/src/server/services.ts +69 -0
  156. package/src/server/text.ts +29 -0
  157. package/src/server/types.ts +160 -0
  158. package/src/server.tsx +82 -964
  159. package/src/shape.mjs +30 -6
  160. package/src/share/display.ts +50 -0
  161. package/src/share/network.ts +119 -0
  162. package/src/share/receive.ts +70 -0
  163. package/src/share/state.ts +30 -0
  164. package/src/share/tab-queue.ts +28 -0
  165. package/src/share-server.ts +128 -43
  166. package/src/share.ts +103 -209
  167. package/src/sheets.mjs +30 -18
  168. package/src/sidecar/build.mjs +170 -0
  169. package/src/sidecar/cargo.mjs +164 -0
  170. package/src/sidecar/common.mjs +17 -0
  171. package/src/sidecar/dev-build.mjs +91 -0
  172. package/src/sidecar/dev-watch.mjs +88 -0
  173. package/src/sidecar/manifest.mjs +19 -0
  174. package/src/sidecar/process.mjs +58 -0
  175. package/src/sidecar/refresh.mjs +19 -0
  176. package/src/sidecar.mjs +75 -416
  177. package/src/socket/check.ts +50 -0
  178. package/src/socket/deliver.ts +16 -0
  179. package/src/socket/publish.ts +56 -0
  180. package/src/socket/rooms.ts +41 -0
  181. package/src/socket/route.ts +79 -0
  182. package/src/socket/shared.ts +26 -0
  183. package/src/socket/types.ts +48 -0
  184. package/src/socket.ts +205 -194
  185. package/src/stream.ts +10 -1
  186. package/src/verify/app.ts +39 -0
  187. package/src/verify/cleanup.ts +27 -0
  188. package/src/verify/database.ts +11 -0
  189. package/src/verify/live.ts +63 -0
  190. package/src/verify/notify.ts +35 -0
  191. package/src/verify/skip.ts +19 -0
  192. package/src/verify/support.ts +149 -0
  193. package/src/verify/timing.ts +15 -0
  194. package/src/verify/trigger.ts +14 -0
  195. package/src/verify.ts +77 -176
  196. package/src/view.tsx +96 -11
  197. package/src/vite/asset-bytes.mjs +95 -0
  198. package/src/vite/assets.mjs +117 -0
  199. package/src/vite/config-hooks.mjs +166 -0
  200. package/src/vite/dev-hooks.mjs +103 -0
  201. package/src/vite/dev.mjs +102 -0
  202. package/src/vite/entries.mjs +145 -0
  203. package/src/vite/module-hooks.mjs +288 -0
  204. package/src/vite/route-modules.mjs +289 -0
  205. package/src/vite/server-functions.mjs +222 -0
  206. package/src/vite/shape-entry-css.mjs +187 -0
  207. package/src/vite/shape-override.mjs +226 -0
  208. package/src/vite/shared.mjs +51 -0
  209. package/src/vite/sheets.mjs +92 -0
  210. package/src/vite/state.mjs +89 -0
  211. package/src/vite/syntax.mjs +139 -0
  212. package/src/vite.mjs +152 -853
  213. package/src/capture.d.mts +0 -1
package/src/extension.ts CHANGED
@@ -25,7 +25,10 @@ export interface ServerExtension {
25
25
  /** Merged into `ctx`, after the app's own. */
26
26
  context?(ctx: Context): object
27
27
  /** Before a function's `run`. Throw to refuse: an error with `answer: { code, message, status }` reaches the client as it is. */
28
- guard?(fn: { id: string; kind: string; def: Record<string, unknown> }, ctx: Context): void | Promise<void>
28
+ guard?(
29
+ fn: { id: string; kind: string; def: Record<string, unknown> },
30
+ ctx: Context,
31
+ ): void | Promise<void>
29
32
  /** The `FnOptions` keys it enforces. One no extension claims refuses the call: a guard that isn't there mustn't pass. */
30
33
  options?: readonly string[]
31
34
  /** The `ctx` keys that differ per user, which a shared cache may not read (Q006). */
@@ -35,4 +38,7 @@ export interface ServerExtension {
35
38
  }
36
39
 
37
40
  /** One extension in the generated list, loaded when first asked for. */
38
- export interface ExtensionEntry { key: string; load(): Promise<ServerExtension> }
41
+ export interface ExtensionEntry {
42
+ key: string
43
+ load(): Promise<ServerExtension>
44
+ }
@@ -4,11 +4,18 @@
4
4
  import { markup } from '@weft-js/core/markup'
5
5
 
6
6
  export interface Pieces {
7
- names: string[]; layers: string; wraps: string[]; pieces: [number, number, string, string][]
8
- always: number[]; alts: ([string[], string[][]][] | null)[]; deps: number[][]; index: Record<string, number[]>
7
+ names: string[]
8
+ layers: string
9
+ wraps: string[]
10
+ pieces: [number, number, string, string][]
11
+ always: number[]
12
+ alts: ([string[], string[][]][] | null)[]
13
+ deps: number[][]
14
+ index: Record<string, number[]>
9
15
  }
10
16
 
11
- const GATE = '[data-weft=styled]{visibility:hidden;animation:weft-gate 0s 3s forwards}@keyframes weft-gate{to{visibility:visible}}'
17
+ const GATE =
18
+ '[data-weft=styled]{visibility:hidden;animation:weft-gate 0s 3s forwards}@keyframes weft-gate{to{visibility:visible}}'
12
19
  const depth = (open: string) => open.split('{').length - 1
13
20
 
14
21
  /**
@@ -19,7 +26,8 @@ export function firstPaint(table: Pieces, sheets: string[], head = '') {
19
26
  const rank = new Map(sheets.map((name, i) => [table.names.indexOf(name), i]))
20
27
  const sent = new Set<number>()
21
28
  const headHas = markup('')
22
- for (const [, name, value] of head.matchAll(/dataset\.(\w+)\s*=\s*['"]([^'"]+)['"]/g)) headHas.add(`[data-${name}=${value}]`)
29
+ for (const [, name, value] of head.matchAll(/dataset\.(\w+)\s*=\s*['"]([^'"]+)['"]/g))
30
+ headHas.add(`[data-${name}=${value}]`)
23
31
  let gated = false
24
32
  return (html: string) => {
25
33
  const has = markup(html)
@@ -32,13 +40,22 @@ export function firstPaint(table: Pieces, sheets: string[], head = '') {
32
40
  table.deps[id]!.forEach(take)
33
41
  }
34
42
  table.always.forEach(take)
35
- for (const key of has) table.index[key]?.forEach(id => {
36
- const need = table.alts[id]
37
- if (!need || need.some(([all, any]) => all.every(k => has.has(k)) && any.every(l => l.some(k => has.has(k))))) take(id)
38
- })
43
+ for (const key of has)
44
+ table.index[key]?.forEach((id) => {
45
+ const need = table.alts[id]
46
+ if (
47
+ !need ||
48
+ need.some(
49
+ ([all, any]) =>
50
+ all.every((k) => has.has(k)) && any.every((l) => l.some((k) => has.has(k))),
51
+ )
52
+ )
53
+ take(id)
54
+ })
39
55
  let css = !gated && has.has('[data-weft=styled]') ? ((gated = true), GATE) : ''
40
56
  fresh.sort((a, b) => rank.get(table.pieces[a]![0])! - rank.get(table.pieces[b]![0])! || a - b)
41
- let open = 0, rule = ''
57
+ let open = 0,
58
+ rule = ''
42
59
  for (const id of fresh) {
43
60
  const [, wrap, selector, body] = table.pieces[id]!
44
61
  if (wrap !== open) {
@@ -0,0 +1,15 @@
1
+ /** The raw values restored after a failed form submission. */
2
+ export type FormValue = string | string[] | undefined
3
+
4
+ /** A field's restoration mode. Omit it to retain the raw value. */
5
+ export type FieldKind = 'text' | 'email' | 'multiple' | 'raw'
6
+
7
+ /** Project a restored value to the declared field kind. */
8
+ export function fieldValue(value: FormValue, kind: 'text' | 'email'): string | undefined
9
+ export function fieldValue(value: FormValue, kind: 'multiple'): string[] | undefined
10
+ export function fieldValue(value: FormValue, kind?: FieldKind): FormValue
11
+ export function fieldValue(value: FormValue, kind?: FieldKind): FormValue {
12
+ if (kind === 'text' || kind === 'email') return Array.isArray(value) ? value[0] : value
13
+ if (kind === 'multiple') return value === undefined ? undefined : [value].flat()
14
+ return value
15
+ }
package/src/form.tsx CHANGED
@@ -5,15 +5,37 @@
5
5
  //
6
6
  // Reachable only from routes that import it, so an app without forms doesn't pay.
7
7
 
8
- import { derived, flush, state, untrack, type Derived } from '@weft-js/core'
9
- import { currentScope, decodeForm, fieldsOf, fnOf, StartError, SUBMIT, type FnError, type Mutation } from './data'
8
+ import { derived, flush, state, untrack, type Derived, type State } from '@weft-js/core'
9
+ import {
10
+ currentScope,
11
+ decodeForm,
12
+ fieldsOf,
13
+ fnOf,
14
+ StartError,
15
+ SUBMIT,
16
+ type FnError,
17
+ type Mutation,
18
+ } from './data'
10
19
  import type { FormFailure } from './exec'
20
+ import { fieldValue, type FieldKind, type FormValue } from './form/value'
11
21
 
12
- /** What a failed form's page gives back for a field: what was typed. */
13
- export type FormValue = string | string[] | undefined
22
+ export type { FormValue } from './form/value'
14
23
 
15
24
  /** A mutation input's field names: `title`, and `a.b` one level down. */
16
- type Field<I> = I extends object ? { [K in keyof I & string]: NonNullable<I[K]> extends object ? K | `${K}.${keyof NonNullable<I[K]> & string}` : K }[keyof I & string] : string
25
+ type Field<I> = I extends object
26
+ ? {
27
+ [K in keyof I & string]: NonNullable<I[K]> extends object
28
+ ? K | `${K}.${keyof NonNullable<I[K]> & string}`
29
+ : K
30
+ }[keyof I & string]
31
+ : string
32
+
33
+ type FieldProps<V> = {
34
+ name: string
35
+ value?: V
36
+ 'aria-invalid'?: true
37
+ 'aria-describedby'?: string
38
+ }
17
39
 
18
40
  /** The state and fields `<Form>` gives its children. */
19
41
  export interface FormApi<I = unknown, O = unknown> {
@@ -30,7 +52,9 @@ export interface FormApi<I = unknown, O = unknown> {
30
52
  /** What was typed, when a no-JS submit came back with errors. */
31
53
  value(name: Field<I>): FormValue
32
54
  /** Spread on the input: `name`, the value to restore, and the invalid state. */
33
- field(name: Field<I>): { name: string; value?: FormValue; 'aria-invalid'?: true; 'aria-describedby'?: string }
55
+ field(name: Field<I>, kind: 'text' | 'email'): FieldProps<string>
56
+ field(name: Field<I>, kind: 'multiple'): FieldProps<string[]>
57
+ field(name: Field<I>, kind?: FieldKind): FieldProps<FormValue>
34
58
  /** The field's error text, in a live region that's in the page before it has anything to say. */
35
59
  Error(props: { name: Field<I>; class?: string }): unknown
36
60
  }
@@ -44,7 +68,7 @@ export interface FormProps<I, O> {
44
68
  reset?: boolean
45
69
  onSuccess?: (result: O) => void
46
70
  onError?: (error: unknown) => void
47
- children?: unknown
71
+ children?: ((form: FormApi<I, O>) => unknown) | string | number | boolean | object | null
48
72
  [attr: string]: unknown
49
73
  }
50
74
 
@@ -67,50 +91,38 @@ function failure(id: string, key?: string): FormFailure | undefined {
67
91
  return f
68
92
  }
69
93
 
70
- const at = (values: Record<string, unknown> | undefined, name: string) => name.split('.').reduce<unknown>((v, k) => (v as Record<string, unknown> | undefined)?.[k], values) as FormValue
71
-
72
- /** A form that posts to a mutation, and works before any code has loaded. */
73
- export function Form<I, O>({ of, id, reset, onSuccess, onError, children, ...rest }: FormProps<I, O>) {
74
- const m = untrack(() => of) as Mutation<I, O> & { [SUBMIT]: (input: unknown, form: true | FormData) => Promise<O> }
75
- const key = untrack(() => id)
76
- const fnId = fnOf(m)?.$id
77
- if (fnOf(m)?.kind !== 'mutation') throw new StartError('F001', '`<Form of>` needs a mutation', '`<Form of={addTodo}>`, with `addTodo` a `mutation()` export of a server module')
78
- const seed = failure(fnId, key)
79
- const errors = state<Record<string, string>>(seed?.errors ?? {})
80
- const message = state(seed?.message)
81
- const result = state<O | undefined>(undefined)
82
- const calls = state(0)
83
- const errorId = (name: string) => `${key ?? fnId}-${name}-error`
84
-
85
- const form: FormApi<I, O> = {
86
- pending: derived(() => calls.value > 0),
87
- errors,
88
- message,
89
- result,
90
- error: name => errors.get(name),
91
- value: name => at(seed?.values, name),
92
- field(name) {
93
- const value = at(seed?.values, name)
94
- const invalid = !!errors.get(name)
95
- return { name, ...(value !== undefined && { value }), ...(invalid && { 'aria-invalid': true as const, 'aria-describedby': errorId(name) }) }
96
- },
97
- Error: props => <p id={errorId(props.name)} class={props.class} role="alert">{errors.get(props.name)}</p>,
98
- }
94
+ const at = (values: Record<string, unknown> | undefined, name: string) =>
95
+ name
96
+ .split('.')
97
+ .reduce<unknown>((v, k) => (v as Record<string, unknown> | undefined)?.[k], values) as FormValue
99
98
 
100
- async function submit(e: SubmitEvent) {
99
+ function createSubmit<O>(
100
+ m: { [SUBMIT]: (input: unknown, form: true | FormData) => Promise<O> },
101
+ o: {
102
+ calls: State<number>
103
+ errors: State<Record<string, string>>
104
+ message: State<string | undefined>
105
+ result: State<O | undefined>
106
+ reset: () => boolean | undefined
107
+ onSuccess: () => ((result: O) => void) | undefined
108
+ onError: () => ((error: unknown) => void) | undefined
109
+ },
110
+ ) {
111
+ const { calls, errors, message, result, reset, onSuccess, onError } = o
112
+ return async function submit(e: SubmitEvent) {
101
113
  e.preventDefault()
102
114
  if (calls.value) return
103
115
  const el = e.target as HTMLFormElement
104
116
  const data = new FormData(el, e.submitter)
105
- const files = [...data.values()].some(v => typeof v !== 'string' && v.size)
117
+ const files = [...data.values()].some((v) => typeof v !== 'string' && v.size)
106
118
  calls.value++
107
119
  try {
108
120
  const v = await m[SUBMIT](decodeForm(data), files ? data : true)
109
121
  errors.value = {}
110
122
  message.value = undefined
111
123
  result.value = v
112
- if (untrack(() => reset) !== false) el.reset()
113
- untrack(() => onSuccess)?.(v)
124
+ if (untrack(reset) !== false) el.reset()
125
+ untrack(onSuccess)?.(v)
114
126
  } catch (err) {
115
127
  const issues = (err as FnError).issues
116
128
  const f = fieldsOf(issues)
@@ -118,17 +130,94 @@ export function Form<I, O>({ of, id, reset, onSuccess, onError, children, ...res
118
130
  message.value = issues ? f.message : (err as Error).message
119
131
  flush()
120
132
  el.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
121
- untrack(() => onError)?.(err)
133
+ untrack(onError)?.(err)
122
134
  } finally {
123
135
  calls.value--
124
136
  }
125
137
  }
138
+ }
139
+
140
+ function invalidMutation() {
141
+ return new StartError(
142
+ 'F001',
143
+ '`<Form of>` needs a mutation',
144
+ '`<Form of={addTodo}>`, with `addTodo` a `mutation()` export of a server module',
145
+ )
146
+ }
147
+
148
+ /** A form that posts to a mutation, and works before any code has loaded. */
149
+ export function Form<I, O>({
150
+ of,
151
+ id,
152
+ reset,
153
+ onSuccess,
154
+ onError,
155
+ children,
156
+ ...rest
157
+ }: FormProps<I, O>) {
158
+ const m = untrack(() => of) as Mutation<I, O> & {
159
+ [SUBMIT]: (input: unknown, form: true | FormData) => Promise<O>
160
+ }
161
+ const key = untrack(() => id)
162
+ const fnId = fnOf(m)?.$id
163
+ if (fnOf(m)?.kind !== 'mutation') throw invalidMutation()
164
+ const seed = failure(fnId, key)
165
+ const errors = state<Record<string, string>>(seed?.errors ?? {})
166
+ const message = state(seed?.message)
167
+ const result = state<O | undefined>(undefined)
168
+ const calls = state(0)
169
+ const errorId = (name: string) => `${key ?? fnId}-${name}-error`
170
+
171
+ function field(name: Field<I>, kind: 'text' | 'email'): FieldProps<string>
172
+ function field(name: Field<I>, kind: 'multiple'): FieldProps<string[]>
173
+ function field(name: Field<I>, kind?: FieldKind): FieldProps<FormValue>
174
+ function field(name: Field<I>, kind?: FieldKind): FieldProps<FormValue> {
175
+ const value = fieldValue(at(seed?.values, name), kind)
176
+ const invalid = !!errors.get(name)
177
+ return {
178
+ name,
179
+ ...(value !== undefined && { value }),
180
+ ...(invalid && { 'aria-invalid': true as const, 'aria-describedby': errorId(name) }),
181
+ }
182
+ }
183
+
184
+ const form: FormApi<I, O> = {
185
+ pending: derived(() => calls.value > 0),
186
+ errors,
187
+ message,
188
+ result,
189
+ error: (name) => errors.get(name),
190
+ value: (name) => at(seed?.values, name),
191
+ field,
192
+ Error: (props) => (
193
+ <p id={errorId(props.name)} class={props.class} role="alert">
194
+ {errors.get(props.name)}
195
+ </p>
196
+ ),
197
+ }
198
+
199
+ const submit = createSubmit(m, {
200
+ calls,
201
+ errors,
202
+ message,
203
+ result,
204
+ reset: () => reset,
205
+ onSuccess: () => onSuccess,
206
+ onError: () => onError,
207
+ })
126
208
 
127
209
  return (
128
- <form method="post" {...rest} aria-busy={form.pending.value || undefined} onSubmit={e => void submit(e)}>
210
+ <form
211
+ method="post"
212
+ {...rest}
213
+ aria-busy={form.pending.value || undefined}
214
+ onSubmit={(e) => void submit(e)}
215
+ >
129
216
  <input type="hidden" name="$fn" value={fnId} />
130
217
  {key !== undefined && <input type="hidden" name="$form" value={key} />}
131
- {typeof children === 'function' ? (children as (api: FormApi<I, O>) => unknown)(form) : children}
218
+ {typeof children === 'function'
219
+ ? (children as (api: FormApi<I, O>) => unknown)(form)
220
+ : children}
132
221
  </form>
133
222
  )
134
223
  }
@@ -0,0 +1,102 @@
1
+ import type { Bus } from '../bus'
2
+ import type { Channel, Ev, Limits } from '../hub'
3
+
4
+ const VOLATILE = '{"v":1'
5
+ const unref = <T>(t: T) => ((t as { unref?(): void } | null | undefined)?.unref?.(), t)
6
+
7
+ interface Rings {
8
+ bus: Bus
9
+ limits: Limits
10
+ epoch: string
11
+ channels: Map<string, Channel>
12
+ seq: number
13
+ }
14
+
15
+ function trim(state: Rings, ch: Channel) {
16
+ const old = Date.now() - state.limits.window
17
+ let drop = 0
18
+ while (
19
+ drop < ch.ring.length &&
20
+ (ch.ring[drop]!.at < old || ch.ring.length - drop > state.limits.ring)
21
+ )
22
+ drop++
23
+ if (!drop) return
24
+ ch.floor = ch.ring[drop - 1]!.seq
25
+ ch.ring.splice(0, drop)
26
+ }
27
+
28
+ function channel(state: Rings, name: string): Channel {
29
+ const { channels, bus } = state
30
+ const ch = channels.get(name)
31
+ if (ch) {
32
+ clearTimeout(ch.idle)
33
+ ch.idle = undefined
34
+ return ch
35
+ }
36
+ const made: Channel = {
37
+ name,
38
+ ring: [],
39
+ floor: state.seq,
40
+ listeners: new Set(),
41
+ got: undefined as never,
42
+ ready: undefined as never,
43
+ }
44
+ channels.set(name, made)
45
+ made.got = (raw) => {
46
+ const ev = { seq: ++state.seq, at: Date.now(), raw }
47
+ // Cursors and presence beats are only worth having now: in the ring they'd push out what a resume needs.
48
+ if (!raw.startsWith(VOLATILE)) {
49
+ made.ring.push(ev)
50
+ trim(state, made)
51
+ }
52
+ for (const on of made.listeners) on(ev)
53
+ }
54
+ made.ready = bus
55
+ .subscribe(name, made.got, () => {
56
+ made.ring.length = 0
57
+ made.floor = state.seq
58
+ for (const on of made.listeners) on(null)
59
+ })
60
+ .then(
61
+ (stop) => {
62
+ made.stop = stop
63
+ made.floor = state.seq
64
+ },
65
+ (e) => {
66
+ channels.delete(name)
67
+ throw e
68
+ },
69
+ )
70
+ made.ready.catch(() => {})
71
+ return made
72
+ }
73
+
74
+ function unlisten(state: Rings, ch: Channel, on: (ev: Ev | null) => void) {
75
+ ch.listeners.delete(on)
76
+ if (ch.listeners.size || ch.idle) return
77
+ ch.idle = unref(
78
+ setTimeout(() => {
79
+ ch.stop?.()
80
+ state.channels.delete(ch.name)
81
+ }, state.limits.window),
82
+ )
83
+ }
84
+
85
+ function replay(state: Rings, ch: Channel, after: string): Ev[] | undefined {
86
+ const [e, n] = after.split(':')
87
+ const at = Number(n)
88
+ trim(state, ch)
89
+ if (e !== state.epoch || !(at >= ch.floor)) return undefined
90
+ return ch.ring.filter((ev) => ev.seq > at)
91
+ }
92
+
93
+ export function createChannels(bus: Bus, limits: Limits, epoch: string) {
94
+ const state: Rings = { bus, limits, epoch, channels: new Map(), seq: 0 }
95
+ return {
96
+ channels: state.channels,
97
+ seq: () => state.seq,
98
+ channel: (name: string) => channel(state, name),
99
+ unlisten: (ch: Channel, on: (ev: Ev | null) => void) => unlisten(state, ch, on),
100
+ replay: (ch: Channel, after: string) => replay(state, ch, after),
101
+ }
102
+ }
@@ -0,0 +1,147 @@
1
+ import { StartError, type Context } from '../data'
2
+ import { createScope, guard, type Scope, type ScopeOptions } from '../exec'
3
+ import type { Source } from '../changes'
4
+ import type { Body, Channel, Conn, HubOptions, Limits, Sink, Sub } from './types'
5
+
6
+ const unref = <T>(t: T) => ((t as { unref?(): void } | null | undefined)?.unref?.(), t)
7
+ interface Dependencies {
8
+ conns: Map<string, Conn>
9
+ limits: Limits
10
+ epoch: string
11
+ options: HubOptions
12
+ source: Source | undefined
13
+ pokes: () => Channel
14
+ want: () => Promise<unknown> | undefined
15
+ rest: () => void
16
+ cursorOf: (n: number) => string
17
+ seq: () => number
18
+ wire: () => { m: string; d?: number }
19
+ change: (c: Conn, request: Request, opts: ScopeOptions, body: Body) => Promise<void>
20
+ replayPokes: (c: Conn, after: string) => void
21
+ fail: (c: Conn, s: Sub, e: unknown) => void
22
+ }
23
+
24
+ function bye(deps: Dependencies, c: Conn, retry: number) {
25
+ c.sink.write('b', JSON.stringify({ retry }))
26
+ close(deps, c)
27
+ }
28
+
29
+ function close(deps: Dependencies, c: Conn) {
30
+ const { conns, rest } = deps
31
+ if (!conns.delete(c.k)) return
32
+ for (const t of c.timers) clearTimeout(t)
33
+ for (const s of c.subs.values()) s.ac.abort()
34
+ c.subs.clear()
35
+ c.sink.close()
36
+ rest()
37
+ }
38
+
39
+ async function recheck(deps: Dependencies, c: Conn) {
40
+ const { conns, fail } = deps
41
+ const authed = [...c.subs.values()].filter((s) => s.fn.def.auth)
42
+ if (!authed.length) return
43
+ const ctx = await createScope(authed[0]!.request, authed[0]!.options)
44
+ .ctx()
45
+ .catch(() => ({}) as Context)
46
+ if (!conns.has(c.k)) return
47
+ for (const s of authed) {
48
+ try {
49
+ guard(s.fn, ctx)
50
+ } catch (e) {
51
+ fail(c, s, e)
52
+ }
53
+ }
54
+ }
55
+
56
+ async function connect(
57
+ deps: Dependencies,
58
+ a: { request: Request; scope: Scope; body: Body; sink: Sink; line: boolean },
59
+ ) {
60
+ const { request, scope, body, sink, line } = a
61
+ const {
62
+ conns,
63
+ limits,
64
+ epoch,
65
+ options,
66
+ source,
67
+ pokes,
68
+ want,
69
+ cursorOf,
70
+ seq,
71
+ wire,
72
+ change,
73
+ replayPokes,
74
+ } = deps
75
+ const opts = scope.options
76
+ const ctx = await scope.ctx()
77
+ const user = (ctx as { user?: { id?: string } }).user?.id
78
+ if (
79
+ !line &&
80
+ user !== undefined &&
81
+ [...conns.values()].filter((c) => c.user === user).length >= limits.connections
82
+ ) {
83
+ throw new StartError(
84
+ 'R009',
85
+ `user \`${user}\` already has ${limits.connections} realtime connections`,
86
+ 'close tabs, or raise `limits.connections`',
87
+ { status: 429 },
88
+ )
89
+ }
90
+ const c: Conn = {
91
+ k: crypto.randomUUID(),
92
+ sink,
93
+ user,
94
+ subs: new Map(),
95
+ tags: new Map(),
96
+ held: new Set(),
97
+ bytes: 0,
98
+ timers: [],
99
+ }
100
+ conns.set(c.k, c)
101
+ await pokes().ready.catch((e) => {
102
+ conns.delete(c.k)
103
+ throw e
104
+ })
105
+ await want() // before `h`: a client that reads next must not miss a change
106
+ sink.write(
107
+ 'h',
108
+ JSON.stringify({
109
+ k: c.k,
110
+ e: epoch,
111
+ b: options.build ?? '',
112
+ c: cursorOf(seq()),
113
+ ...(source && { l: wire() }),
114
+ }),
115
+ )
116
+ await change(c, request, opts, body)
117
+ if (body.c) replayPokes(c, body.c)
118
+ startTimers(deps, c, sink)
119
+ return c
120
+ }
121
+
122
+ function startTimers(deps: Dependencies, c: Conn, sink: Sink) {
123
+ const { limits, options } = deps
124
+ let ticks = 0
125
+ const every = Math.max(1, Math.round(limits.session / limits.heartbeat))
126
+ c.timers.push(
127
+ unref(
128
+ setInterval(() => {
129
+ sink.ping?.()
130
+ if (++ticks % every === 0) void recheck(deps, c)
131
+ }, limits.heartbeat),
132
+ ),
133
+ )
134
+ if (options.maxDuration)
135
+ c.timers.push(
136
+ unref(setTimeout(() => bye(deps, c, 0), Math.max(1, options.maxDuration - 10) * 1000)),
137
+ )
138
+ }
139
+
140
+ export function createConnections(deps: Dependencies) {
141
+ return {
142
+ bye: (c: Conn, retry: number) => bye(deps, c, retry),
143
+ close: (c: Conn) => close(deps, c),
144
+ connect: (request: Request, scope: Scope, body: Body, sink: Sink, line = false) =>
145
+ connect(deps, { request, scope, body, sink, line }),
146
+ }
147
+ }
@@ -0,0 +1,62 @@
1
+ import type { Conn, Limits } from '../hub'
2
+
3
+ type Sub = Conn['subs'] extends Map<number, infer S> ? S : never
4
+
5
+ const HIGH = 64 * 1024
6
+
7
+ export function createDelivery(
8
+ limits: Limits,
9
+ cursorOf: (n: number) => string,
10
+ bye: (c: Conn, retry: number) => void,
11
+ ) {
12
+ function poke(c: Conn, tags: string[], at: number) {
13
+ if (!tags.length) return
14
+ if (!c.held.size && c.sink.backlog() < HIGH)
15
+ return c.sink.write('p', JSON.stringify({ t: tags, c: cursorOf(at) }))
16
+ const p = (c.pokes ??= { t: new Set(), c: '' })
17
+ for (const t of tags) p.t.add(t)
18
+ p.c = cursorOf(at)
19
+ }
20
+
21
+ /** Over `queue`: the client gets a bye, reconnects and resumes, rather than this process growing. */
22
+ function hold(c: Conn, s: Sub, raw: string): boolean {
23
+ const size = raw.length
24
+ if (!s.buffer) {
25
+ for (const x of s.q) c.bytes -= x.size
26
+ s.q.length = 0
27
+ } else if (s.q.length >= s.buffer) {
28
+ for (const x of s.q) c.bytes -= x.size
29
+ s.q.length = 0
30
+ return false
31
+ }
32
+ s.q.push({ raw, size })
33
+ c.bytes += size
34
+ c.held.add(s)
35
+ if (c.bytes > limits.queue) bye(c, 0)
36
+ return true
37
+ }
38
+
39
+ /** Written to the client, or held. False: the buffer overflowed, so this stream resets. */
40
+ function deliver(c: Conn, s: Sub, raw: string): boolean {
41
+ if (!c.held.size && c.sink.backlog() < HIGH) return (c.sink.write('m', raw), true)
42
+ return hold(c, s, raw)
43
+ }
44
+
45
+ function drain(c: Conn) {
46
+ if (c.pokes && c.sink.backlog() < HIGH) {
47
+ c.sink.write('p', JSON.stringify({ t: [...c.pokes.t], c: c.pokes.c }))
48
+ c.pokes = undefined
49
+ }
50
+ for (const s of c.held) {
51
+ while (s.q.length && c.sink.backlog() < HIGH) {
52
+ const x = s.q.shift()!
53
+ c.bytes -= x.size
54
+ c.sink.write('m', x.raw)
55
+ }
56
+ if (s.q.length) return
57
+ c.held.delete(s)
58
+ }
59
+ }
60
+
61
+ return { poke, deliver, drain }
62
+ }