@weft-js/start 0.0.0-stage → 0.1.1
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/LICENSE +16 -0
- package/README.md +41 -2
- package/package.json +107 -4
- package/src/app.mjs +93 -0
- package/src/auth/better-auth.ts +58 -0
- package/src/auth/drizzle.ts +17 -0
- package/src/auth.ts +47 -0
- package/src/boot.ts +44 -0
- package/src/builtin.tsx +19 -0
- package/src/bus/postgres.ts +91 -0
- package/src/bus.ts +48 -0
- package/src/capture.d.mts +1 -0
- package/src/capture.mjs +15 -0
- package/src/changes-sql.d.mts +14 -0
- package/src/changes-sql.mjs +99 -0
- package/src/changes.ts +253 -0
- package/src/channel-fake.ts +55 -0
- package/src/channel.ts +622 -0
- package/src/client.tsx +20 -0
- package/src/contract.mjs +312 -0
- package/src/data.ts +929 -0
- package/src/db/bun-sqlite.d.ts +10 -0
- package/src/db/changes-mysql.ts +86 -0
- package/src/db/changes-postgres.ts +159 -0
- package/src/db/changes-sqlite.ts +116 -0
- package/src/db/core.ts +63 -0
- package/src/db/index.ts +27 -0
- package/src/db/mysql.ts +67 -0
- package/src/db/postgres.ts +65 -0
- package/src/db/sqlite.ts +139 -0
- package/src/derive.d.mts +16 -0
- package/src/derive.mjs +140 -0
- package/src/dev-runtime.mjs +42 -0
- package/src/exec.ts +519 -0
- package/src/extension.ts +38 -0
- package/src/first-paint.ts +57 -0
- package/src/form.tsx +134 -0
- package/src/gate.css +6 -0
- package/src/hub.ts +822 -0
- package/src/index.ts +106 -0
- package/src/inline.mjs +2 -0
- package/src/inspect.mjs +280 -0
- package/src/link.ts +461 -0
- package/src/list-drizzle.ts +50 -0
- package/src/list.ts +224 -0
- package/src/listen.ts +28 -0
- package/src/live-fake.ts +42 -0
- package/src/live-target.mjs +147 -0
- package/src/live.ts +499 -0
- package/src/media.ts +43 -0
- package/src/migrations.mjs +19 -0
- package/src/mux-session.ts +6 -0
- package/src/mux.ts +135 -0
- package/src/og/DefaultCard.tsx +60 -0
- package/src/og/fonts.mjs +64 -0
- package/src/og/images.mjs +54 -0
- package/src/og/index.mjs +146 -0
- package/src/og/native.mjs +29 -0
- package/src/range.mjs +19 -0
- package/src/router.ts +149 -0
- package/src/routes.mjs +142 -0
- package/src/run.tsx +202 -0
- package/src/rust.ts +333 -0
- package/src/server.tsx +1021 -0
- package/src/shape.d.mts +3 -0
- package/src/shape.mjs +73 -0
- package/src/share-server.ts +208 -0
- package/src/share.ts +503 -0
- package/src/sheets.mjs +75 -0
- package/src/sidecar.mjs +503 -0
- package/src/socket.ts +389 -0
- package/src/stream.ts +13 -0
- package/src/verify.ts +251 -0
- package/src/view.tsx +47 -0
- package/src/vite.mjs +899 -0
package/src/form.tsx
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// `<Form of={mutation}>` (research 29 §5). The markup is a real `<form method="post">` with the mutation's id in a
|
|
2
|
+
// hidden field, so it works before and without JS: the server runs the mutation and answers 303, or the same page
|
|
3
|
+
// with the errors (server.tsx). Once hydrated, a submit calls the mutation in place, with `optimistic` and
|
|
4
|
+
// single-flight as in any call. Validation is the server's in both cases: client stubs have no schema.
|
|
5
|
+
//
|
|
6
|
+
// Reachable only from routes that import it, so an app without forms doesn't pay.
|
|
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'
|
|
10
|
+
import type { FormFailure } from './exec'
|
|
11
|
+
|
|
12
|
+
/** What a failed form's page gives back for a field: what was typed. */
|
|
13
|
+
export type FormValue = string | string[] | undefined
|
|
14
|
+
|
|
15
|
+
/** 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
|
|
17
|
+
|
|
18
|
+
/** The state and fields `<Form>` gives its children. */
|
|
19
|
+
export interface FormApi<I = unknown, O = unknown> {
|
|
20
|
+
/** This form's submit is in flight (`mutation.pending` is every caller's). */
|
|
21
|
+
readonly pending: Derived<boolean>
|
|
22
|
+
/** Errors by field, from the schema or `invalid()`. */
|
|
23
|
+
readonly errors: Derived<Record<string, string>>
|
|
24
|
+
/** The failure that names no field. */
|
|
25
|
+
readonly message: Derived<string | undefined>
|
|
26
|
+
/** The last successful result. */
|
|
27
|
+
readonly result: Derived<O | undefined>
|
|
28
|
+
/** One field's error (its reader re-runs only when that field's error changes). */
|
|
29
|
+
error(name: Field<I>): string | undefined
|
|
30
|
+
/** What was typed, when a no-JS submit came back with errors. */
|
|
31
|
+
value(name: Field<I>): FormValue
|
|
32
|
+
/** 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 }
|
|
34
|
+
/** The field's error text, in a live region that's in the page before it has anything to say. */
|
|
35
|
+
Error(props: { name: Field<I>; class?: string }): unknown
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `<Form>`: the mutation, and what to do with its outcome. */
|
|
39
|
+
export interface FormProps<I, O> {
|
|
40
|
+
of: Mutation<I, O>
|
|
41
|
+
/** Tells two forms for one mutation apart on the no-JS path. */
|
|
42
|
+
id?: string
|
|
43
|
+
/** Empty the fields after a success (default true). A no-JS submit always comes back to a fresh page. */
|
|
44
|
+
reset?: boolean
|
|
45
|
+
onSuccess?: (result: O) => void
|
|
46
|
+
onError?: (error: unknown) => void
|
|
47
|
+
children?: unknown
|
|
48
|
+
[attr: string]: unknown
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let page: FormFailure | null | undefined
|
|
52
|
+
|
|
53
|
+
/** The failure this form came back with: the request's on the server, `#weft-form` in the hydrating page. */
|
|
54
|
+
function failure(id: string, key?: string): FormFailure | undefined {
|
|
55
|
+
const scope = currentScope()
|
|
56
|
+
let f: FormFailure | undefined | null = scope?.form
|
|
57
|
+
if (!scope && typeof document === 'object') {
|
|
58
|
+
if (page === undefined) {
|
|
59
|
+
const el = document.getElementById('weft-form')
|
|
60
|
+
page = el && JSON.parse(el.textContent)
|
|
61
|
+
el?.remove()
|
|
62
|
+
}
|
|
63
|
+
f = page
|
|
64
|
+
}
|
|
65
|
+
if (f?.id !== id || f.key !== key) return undefined
|
|
66
|
+
if (!scope) page = null // once: a later visit to this page starts clean
|
|
67
|
+
return f
|
|
68
|
+
}
|
|
69
|
+
|
|
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
|
+
}
|
|
99
|
+
|
|
100
|
+
async function submit(e: SubmitEvent) {
|
|
101
|
+
e.preventDefault()
|
|
102
|
+
if (calls.value) return
|
|
103
|
+
const el = e.target as HTMLFormElement
|
|
104
|
+
const data = new FormData(el, e.submitter)
|
|
105
|
+
const files = [...data.values()].some(v => typeof v !== 'string' && v.size)
|
|
106
|
+
calls.value++
|
|
107
|
+
try {
|
|
108
|
+
const v = await m[SUBMIT](decodeForm(data), files ? data : true)
|
|
109
|
+
errors.value = {}
|
|
110
|
+
message.value = undefined
|
|
111
|
+
result.value = v
|
|
112
|
+
if (untrack(() => reset) !== false) el.reset()
|
|
113
|
+
untrack(() => onSuccess)?.(v)
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const issues = (err as FnError).issues
|
|
116
|
+
const f = fieldsOf(issues)
|
|
117
|
+
errors.value = f.errors
|
|
118
|
+
message.value = issues ? f.message : (err as Error).message
|
|
119
|
+
flush()
|
|
120
|
+
el.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
|
|
121
|
+
untrack(() => onError)?.(err)
|
|
122
|
+
} finally {
|
|
123
|
+
calls.value--
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<form method="post" {...rest} aria-busy={form.pending.value || undefined} onSubmit={e => void submit(e)}>
|
|
129
|
+
<input type="hidden" name="$fn" value={fnId} />
|
|
130
|
+
{key !== undefined && <input type="hidden" name="$form" value={key} />}
|
|
131
|
+
{typeof children === 'function' ? (children as (api: FormApi<I, O>) => unknown)(form) : children}
|
|
132
|
+
</form>
|
|
133
|
+
)
|
|
134
|
+
}
|
package/src/gate.css
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/* The late half of the style gate: `firstPaint` hides `data-weft="styled"` in the page, and this shows it. */
|
|
2
|
+
[data-weft="styled"] { visibility: visible; animation: none; }
|
|
3
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
4
|
+
[data-weft="styled"] { animation: weft-reveal 160ms ease-out; }
|
|
5
|
+
}
|
|
6
|
+
@keyframes weft-reveal { from { opacity: 0; } }
|