@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.
- package/README.md +36 -3
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -0
- package/crate/rust-toolchain.toml +3 -0
- package/crate/src/build/emit.rs +103 -0
- package/crate/src/build.rs +367 -0
- package/crate/src/ctx.rs +186 -0
- package/crate/src/envelope.rs +163 -0
- package/crate/src/functions.rs +173 -0
- package/crate/src/lib.rs +118 -0
- package/crate/src/native.rs +212 -0
- package/crate/src/otel.rs +62 -0
- package/crate/src/server.rs +203 -0
- package/package.json +46 -37
- package/src/app.mjs +51 -10
- package/src/asset-receipt.d.mts +13 -0
- package/src/asset-receipt.mjs +61 -0
- package/src/auth/better-auth.ts +215 -17
- package/src/auth/drizzle.ts +1 -1
- package/src/auth.ts +21 -6
- package/src/boot.ts +19 -8
- package/src/bus/postgres.ts +32 -19
- package/src/bus.ts +7 -4
- package/src/{capture.mjs → capture.ts} +34 -1
- package/src/changes-sql.d.mts +8 -2
- package/src/changes-sql.mjs +26 -10
- package/src/changes.ts +165 -66
- package/src/channel/connections.ts +216 -0
- package/src/channel/socket.ts +396 -0
- package/src/channel-fake.ts +19 -4
- package/src/channel.ts +124 -490
- package/src/client.tsx +9 -2
- package/src/contract/describe.mjs +231 -0
- package/src/contract.mjs +146 -155
- package/src/data/cache.ts +354 -0
- package/src/data/definitions.ts +94 -0
- package/src/data/draft.ts +104 -0
- package/src/data/foundation.ts +95 -0
- package/src/data/live-api.ts +22 -0
- package/src/data/mutation.ts +138 -0
- package/src/data/primitives.ts +135 -0
- package/src/data/query.ts +106 -0
- package/src/data/transport.ts +121 -0
- package/src/data/types.ts +303 -0
- package/src/data.ts +69 -894
- package/src/db/bun-sqlite.d.ts +4 -1
- package/src/db/changes-mysql.ts +61 -21
- package/src/db/changes-postgres.ts +197 -118
- package/src/db/changes-sqlite.ts +131 -64
- package/src/db/core.ts +38 -13
- package/src/db/index.ts +20 -4
- package/src/db/mysql.ts +22 -6
- package/src/db/postgres.ts +49 -13
- package/src/db/sqlite.ts +53 -27
- package/src/derive.d.mts +4 -1
- package/src/derive.mjs +136 -39
- package/src/dev-runtime.mjs +8 -4
- package/src/exec/errors.ts +57 -0
- package/src/exec/landing.ts +66 -0
- package/src/exec/scope.ts +174 -0
- package/src/exec/touch.ts +80 -0
- package/src/exec/validation.ts +176 -0
- package/src/exec.ts +191 -341
- package/src/extension.ts +8 -2
- package/src/first-paint.ts +26 -9
- package/src/form/value.ts +15 -0
- package/src/form.tsx +132 -43
- package/src/hub/channels.ts +102 -0
- package/src/hub/connections.ts +147 -0
- package/src/hub/delivery.ts +62 -0
- package/src/hub/feed.ts +168 -0
- package/src/hub/presence.ts +64 -0
- package/src/hub/route.ts +158 -0
- package/src/hub/subscriptions.ts +212 -0
- package/src/hub/topic.ts +98 -0
- package/src/hub/tracked.ts +12 -0
- package/src/hub/types.ts +190 -0
- package/src/hub/view.ts +147 -0
- package/src/hub.ts +132 -758
- package/src/index.ts +84 -10
- package/src/inline.mjs +356 -0
- package/src/inspect/compare.mjs +162 -0
- package/src/inspect/routes.mjs +154 -0
- package/src/inspect/samples.mjs +59 -0
- package/src/inspect/url.mjs +2 -0
- package/src/inspect.mjs +146 -187
- package/src/link/election.ts +178 -0
- package/src/link/lines.ts +27 -0
- package/src/link.ts +97 -168
- package/src/list-drizzle.ts +104 -18
- package/src/list.ts +127 -36
- package/src/listen.ts +32 -6
- package/src/live/api.ts +18 -0
- package/src/live/backoff.ts +6 -0
- package/src/live/frames.ts +42 -0
- package/src/live/read.ts +77 -0
- package/src/live/sub-types.ts +24 -0
- package/src/live/transport.ts +86 -0
- package/src/live/types.ts +52 -0
- package/src/live-fake.ts +20 -5
- package/src/live-target.mjs +113 -35
- package/src/live.ts +116 -225
- package/src/media.ts +8 -1
- package/src/migrations.mjs +14 -6
- package/src/mux.ts +161 -64
- package/src/og/DefaultCard.tsx +40 -9
- package/src/og/fonts.mjs +16 -8
- package/src/og/images.mjs +43 -22
- package/src/og/index.mjs +119 -43
- package/src/og/native.mjs +11 -4
- package/src/range.mjs +15 -3
- package/src/router.ts +56 -6
- package/src/routes.mjs +73 -22
- package/src/run/admission.ts +80 -0
- package/src/run/boot.tsx +115 -0
- package/src/run/cache-presentation.ts +67 -0
- package/src/run/cache.ts +254 -0
- package/src/run/helpers.ts +197 -0
- package/src/run/listeners.ts +155 -0
- package/src/run/navigation.ts +382 -0
- package/src/run/page-skeleton.tsx +35 -0
- package/src/run/prefetch.ts +128 -0
- package/src/run/retain-view.ts +18 -0
- package/src/run/shape-preparation.ts +97 -0
- package/src/run/shape-styles.ts +72 -0
- package/src/run/source-context/pin.ts +124 -0
- package/src/run/source-context.ts +311 -0
- package/src/run.tsx +50 -174
- package/src/rust/common.ts +163 -0
- package/src/rust/native.ts +282 -0
- package/src/rust.ts +103 -229
- package/src/server/card.tsx +65 -0
- package/src/server/compression.ts +115 -0
- package/src/server/core.ts +70 -0
- package/src/server/dispatch.ts +70 -0
- package/src/server/form.ts +85 -0
- package/src/server/functions.ts +129 -0
- package/src/server/handler.ts +39 -0
- package/src/server/head.ts +96 -0
- package/src/server/media.ts +60 -0
- package/src/server/navigation-cache.ts +80 -0
- package/src/server/navigation.tsx +85 -0
- package/src/server/page-cold.ts +95 -0
- package/src/server/page-stream.tsx +181 -0
- package/src/server/page-types.ts +51 -0
- package/src/server/page-warm.ts +130 -0
- package/src/server/page.ts +69 -0
- package/src/server/payload.ts +89 -0
- package/src/server/prerender.ts +60 -0
- package/src/server/preview.tsx +67 -0
- package/src/server/render.ts +167 -0
- package/src/server/request.ts +98 -0
- package/src/server/resolver.ts +228 -0
- package/src/server/routes.ts +150 -0
- package/src/server/services.ts +69 -0
- package/src/server/text.ts +29 -0
- package/src/server/types.ts +160 -0
- package/src/server.tsx +82 -964
- package/src/shape.mjs +30 -6
- package/src/share/display.ts +50 -0
- package/src/share/network.ts +119 -0
- package/src/share/receive.ts +70 -0
- package/src/share/state.ts +30 -0
- package/src/share/tab-queue.ts +28 -0
- package/src/share-server.ts +128 -43
- package/src/share.ts +103 -209
- package/src/sheets.mjs +30 -18
- package/src/sidecar/build.mjs +170 -0
- package/src/sidecar/cargo.mjs +164 -0
- package/src/sidecar/common.mjs +17 -0
- package/src/sidecar/dev-build.mjs +91 -0
- package/src/sidecar/dev-watch.mjs +88 -0
- package/src/sidecar/manifest.mjs +19 -0
- package/src/sidecar/process.mjs +58 -0
- package/src/sidecar/refresh.mjs +19 -0
- package/src/sidecar.mjs +75 -416
- package/src/socket/check.ts +50 -0
- package/src/socket/deliver.ts +16 -0
- package/src/socket/publish.ts +56 -0
- package/src/socket/rooms.ts +41 -0
- package/src/socket/route.ts +79 -0
- package/src/socket/shared.ts +26 -0
- package/src/socket/types.ts +48 -0
- package/src/socket.ts +205 -194
- package/src/stream.ts +10 -1
- package/src/verify/app.ts +39 -0
- package/src/verify/cleanup.ts +27 -0
- package/src/verify/database.ts +11 -0
- package/src/verify/live.ts +63 -0
- package/src/verify/notify.ts +35 -0
- package/src/verify/skip.ts +19 -0
- package/src/verify/support.ts +149 -0
- package/src/verify/timing.ts +15 -0
- package/src/verify/trigger.ts +14 -0
- package/src/verify.ts +77 -176
- package/src/view.tsx +96 -11
- package/src/vite/asset-bytes.mjs +95 -0
- package/src/vite/assets.mjs +117 -0
- package/src/vite/config-hooks.mjs +166 -0
- package/src/vite/dev-hooks.mjs +103 -0
- package/src/vite/dev.mjs +102 -0
- package/src/vite/entries.mjs +145 -0
- package/src/vite/module-hooks.mjs +288 -0
- package/src/vite/route-modules.mjs +289 -0
- package/src/vite/server-functions.mjs +222 -0
- package/src/vite/shape-entry-css.mjs +187 -0
- package/src/vite/shape-override.mjs +226 -0
- package/src/vite/shared.mjs +51 -0
- package/src/vite/sheets.mjs +92 -0
- package/src/vite/state.mjs +89 -0
- package/src/vite/syntax.mjs +139 -0
- package/src/vite.mjs +152 -853
- package/src/capture.d.mts +0 -1
package/src/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?(
|
|
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 {
|
|
41
|
+
export interface ExtensionEntry {
|
|
42
|
+
key: string
|
|
43
|
+
load(): Promise<ServerExtension>
|
|
44
|
+
}
|
package/src/first-paint.ts
CHANGED
|
@@ -4,11 +4,18 @@
|
|
|
4
4
|
import { markup } from '@weft-js/core/markup'
|
|
5
5
|
|
|
6
6
|
export interface Pieces {
|
|
7
|
-
names: string[]
|
|
8
|
-
|
|
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 =
|
|
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))
|
|
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)
|
|
36
|
-
|
|
37
|
-
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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) =>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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(
|
|
113
|
-
untrack(
|
|
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(
|
|
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
|
|
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'
|
|
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
|
+
}
|