@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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { DEV, flush, untrack, type State } from '@weft-js/core'
|
|
2
|
+
import { client, peers } from '../router'
|
|
3
|
+
import { entries, entry, setBase, staleTags, relieve, cache, type Entry, type Layer } from './cache'
|
|
4
|
+
import { fnOf, fns, go, runtime, server } from './foundation'
|
|
5
|
+
import { FnError } from './primitives'
|
|
6
|
+
import { transport } from './transport'
|
|
7
|
+
import type { Answer, Fn, OptimisticCache } from './types'
|
|
8
|
+
|
|
9
|
+
export async function mutate(
|
|
10
|
+
fn: Fn,
|
|
11
|
+
input: unknown,
|
|
12
|
+
calls: State<number>,
|
|
13
|
+
error: State<unknown>,
|
|
14
|
+
form?: true | FormData,
|
|
15
|
+
): Promise<unknown> {
|
|
16
|
+
const scope = server()
|
|
17
|
+
if (scope) return (await runtime.exec!.runMutation(scope, fn, input, [], !!form)).v
|
|
18
|
+
const m = ++callId
|
|
19
|
+
const touched = new Set<Entry>()
|
|
20
|
+
let overlap = 0
|
|
21
|
+
untrack(() => calls.value++)
|
|
22
|
+
const drop = (e: Entry) => {
|
|
23
|
+
e.layers.value = e.layers.value.filter((l) => l.m !== m)
|
|
24
|
+
}
|
|
25
|
+
const wait = (e: Entry) => {
|
|
26
|
+
e.layers.value = e.layers.value.map((l) => (l.m === m ? { ...l, waiting: true } : l))
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
if (fn.def.optimistic) untrack(() => fn.def.optimistic!({ input }, overlay(m, touched)))
|
|
30
|
+
const refresh = [...entries.values()]
|
|
31
|
+
.filter((e) => e.readers > 0)
|
|
32
|
+
.map((e) => [e.id, e.input] as [string, unknown])
|
|
33
|
+
if (!open++) run++
|
|
34
|
+
overlap = run
|
|
35
|
+
const a = await settled(() => transport().mutate(fn.$id, input, refresh, form))
|
|
36
|
+
if (a.x) client.sessionChanged?.()
|
|
37
|
+
const out = applyResult({ fn, a, touched, m, overlap, error, drop, wait })
|
|
38
|
+
if (a.t?.length) peers?.postMessage(a.t)
|
|
39
|
+
if (a.x) peers?.postMessage(0)
|
|
40
|
+
if (a.r !== undefined) {
|
|
41
|
+
if (a.x) location.assign(a.r)
|
|
42
|
+
else go(a.r)
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
} catch (err) {
|
|
46
|
+
untrack(() => {
|
|
47
|
+
for (const e of touched) {
|
|
48
|
+
drop(e) // only this call's prediction goes
|
|
49
|
+
if (overlap && (!(err instanceof FnError) || err.status >= 500)) e.held = true // the reply was lost, not the write: it may have landed
|
|
50
|
+
}
|
|
51
|
+
flush() // readers roll back now, not when the read below answers
|
|
52
|
+
for (const e of touched) relieve(e)
|
|
53
|
+
error.value = err
|
|
54
|
+
})
|
|
55
|
+
throw err
|
|
56
|
+
} finally {
|
|
57
|
+
untrack(() => calls.value--)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function applyResult(o: {
|
|
61
|
+
fn: Fn
|
|
62
|
+
a: Answer
|
|
63
|
+
touched: Set<Entry>
|
|
64
|
+
m: number
|
|
65
|
+
overlap: number
|
|
66
|
+
error: State<unknown>
|
|
67
|
+
drop: (e: Entry) => void
|
|
68
|
+
wait: (e: Entry) => void
|
|
69
|
+
}) {
|
|
70
|
+
const { fn, a, touched, m, overlap, error, drop, wait } = o
|
|
71
|
+
let out: unknown
|
|
72
|
+
untrack(() => {
|
|
73
|
+
const refreshed = new Set<string>()
|
|
74
|
+
const seen: Entry[] = []
|
|
75
|
+
for (const w of a.c ?? []) {
|
|
76
|
+
const e = entry(fns.get(w[0]) ?? ({ $id: w[0] } as Fn), w[1])
|
|
77
|
+
refreshed.add(e.key)
|
|
78
|
+
seen.push(e)
|
|
79
|
+
// Replies don't say which came first, and one taken in arrival order can put back what a later call changed: where
|
|
80
|
+
// calls overlap on an entry its layers stay, in order, and it reads once the last has answered.
|
|
81
|
+
if (e.held || e.run === overlap || e.layers.value.some((l) => l.m !== m)) {
|
|
82
|
+
e.held = true
|
|
83
|
+
wait(e)
|
|
84
|
+
} else {
|
|
85
|
+
setBase(e, w[2], w[3])
|
|
86
|
+
drop(e)
|
|
87
|
+
e.run = overlap
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const e of touched) {
|
|
91
|
+
if (refreshed.has(e.key)) continue
|
|
92
|
+
// S011: the server sent no fresh value for an entry this call predicted. Keep the layer until the entry's
|
|
93
|
+
// next value (refetched now) instead of flashing back to the old one.
|
|
94
|
+
if (DEV && !e.tags.some((t) => a.t?.includes(t)))
|
|
95
|
+
console.warn(
|
|
96
|
+
`weft: S011 \`${fn.$id}\`'s optimistic updated \`${e.key}\` (tags ${JSON.stringify(e.tags)}), but its invalidated tags ${JSON.stringify(a.t ?? [])} don't cover it: the prediction had no fresh value to land on.\n fix: add one of its tags to \`invalidates\``,
|
|
97
|
+
)
|
|
98
|
+
wait(e)
|
|
99
|
+
e.held = true
|
|
100
|
+
}
|
|
101
|
+
staleTags(a.t ?? [], refreshed)
|
|
102
|
+
for (const e of seen) relieve(e)
|
|
103
|
+
for (const e of touched) relieve(e)
|
|
104
|
+
error.value = undefined
|
|
105
|
+
out = a.v
|
|
106
|
+
})
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let callId = 0
|
|
111
|
+
/** Calls in flight, and which unbroken run of overlapping calls they belong to. */
|
|
112
|
+
let open = 0
|
|
113
|
+
let run = 0
|
|
114
|
+
const settled = async <T>(call: () => Promise<T>) => {
|
|
115
|
+
try {
|
|
116
|
+
return await call()
|
|
117
|
+
} finally {
|
|
118
|
+
open--
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The `cache` an `optimistic` gets: `update` and `set` add this call's layers instead of writing the base. */
|
|
123
|
+
function overlay(m: number, touched: Set<Entry>): OptimisticCache {
|
|
124
|
+
const push = (q: unknown, input: unknown, apply: Layer['apply']) => {
|
|
125
|
+
const e = entry(fnOf(q), input)
|
|
126
|
+
touched.add(e)
|
|
127
|
+
e.layers.value = [...e.layers.value, { m, apply }]
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
...cache,
|
|
131
|
+
update: (q, input, recipe) => push(q, input, recipe as Layer['apply']),
|
|
132
|
+
set: (q, input, value) => push(q, input, () => value),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function resetMutation() {
|
|
137
|
+
callId = open = 0
|
|
138
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { SchemaIssue } from './types'
|
|
2
|
+
|
|
3
|
+
/** A diagnostic thrown by @weft-js/start: `code` (Q001…) and a fix in the message, like Weft's StateError. */
|
|
4
|
+
export class StartError extends Error {
|
|
5
|
+
/** The HTTP status, when `extra` sets one. */
|
|
6
|
+
declare status?: number
|
|
7
|
+
/** Field by field, when `extra` sets them. */
|
|
8
|
+
declare issues?: SchemaIssue[]
|
|
9
|
+
/** What a declared error carries. */
|
|
10
|
+
declare data?: unknown
|
|
11
|
+
/** `raise`: this is a failure the function declares, so its message reaches the client. */
|
|
12
|
+
declare typed?: boolean
|
|
13
|
+
constructor(
|
|
14
|
+
public code: string,
|
|
15
|
+
message: string,
|
|
16
|
+
fix?: string,
|
|
17
|
+
public extra: Record<string, unknown> = {},
|
|
18
|
+
) {
|
|
19
|
+
super(`${code}: ${message}${fix ? `\n fix: ${fix}` : ''}`)
|
|
20
|
+
Object.assign(this, extra)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A failed server function call, as the client sees it. */
|
|
25
|
+
export class FnError extends Error {
|
|
26
|
+
code?: string
|
|
27
|
+
status: number
|
|
28
|
+
issues?: SchemaIssue[]
|
|
29
|
+
/** What a declared error (`errors`) carries. */
|
|
30
|
+
data?: unknown
|
|
31
|
+
constructor(
|
|
32
|
+
e: { message?: string; code?: string; issues?: SchemaIssue[] | undefined; data?: unknown },
|
|
33
|
+
status: number,
|
|
34
|
+
) {
|
|
35
|
+
super(e.message ?? `server function failed (${status})`)
|
|
36
|
+
this.status = status
|
|
37
|
+
Object.assign(this, { code: e.code, issues: e.issues, data: e.data })
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
declare const redirectBrand: unique symbol
|
|
42
|
+
/** What `redirect()` throws: a loader or a function's way to send the page elsewhere. */
|
|
43
|
+
export interface Redirect {
|
|
44
|
+
readonly isRedirect: true
|
|
45
|
+
readonly href: string
|
|
46
|
+
readonly status: number
|
|
47
|
+
readonly [redirectBrand]?: true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Send the request elsewhere: in `load` (a page answers 307 with `location`, client navigation follows) or in a
|
|
52
|
+
* `run` (the client router navigates). It throws, so `(await db.get(id)) ?? redirect('/todos')` types as the row.
|
|
53
|
+
*/
|
|
54
|
+
export function redirect(href: string, status: 301 | 302 | 303 | 307 | 308 = 307): never {
|
|
55
|
+
// oxlint-disable-next-line typescript/only-throw-error -- control flow, not a failure: a stack costs and nothing reads it
|
|
56
|
+
throw { isRedirect: true, href, status } as Redirect
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether a thrown value is a `redirect()`. */
|
|
60
|
+
export const isRedirect = (v: unknown): v is Redirect =>
|
|
61
|
+
!!v && typeof v === 'object' && (v as Partial<Redirect>).isRedirect === true
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Fail with an error the function declares in `errors`. The client sees it as `FnError.code` and `.data`, whether `run`
|
|
65
|
+
* or a Rust `impl` raised it.
|
|
66
|
+
*/
|
|
67
|
+
export function raise(code: string, data?: unknown): never {
|
|
68
|
+
throw new StartError(code, `failed: ${code}`, undefined, { status: 400, data, typed: true })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Refuse a value the schema can't judge (`email` is taken): `<Form>` shows it next to the field, as a schema issue. */
|
|
72
|
+
export function invalid(fields: Record<string, string>): never {
|
|
73
|
+
throw new StartError('Q002', Object.keys(fields).join(', '), undefined, {
|
|
74
|
+
status: 422,
|
|
75
|
+
issues: Object.entries(fields).map(([k, message]) => ({ message, path: k.split('.') })),
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Issues as a form shows them: by field (`a.b`), and the ones that name no field. */
|
|
80
|
+
export function fieldsOf(issues: readonly SchemaIssue[] = []) {
|
|
81
|
+
const errors: Record<string, string> = {}
|
|
82
|
+
let message: string | undefined
|
|
83
|
+
for (const i of issues) {
|
|
84
|
+
const key = i.path?.map((p) => (typeof p === 'object' ? p.key : p)).join('.')
|
|
85
|
+
if (key) errors[key] ??= i.message
|
|
86
|
+
else message ??= i.message
|
|
87
|
+
}
|
|
88
|
+
return { errors, message }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type Tree = Record<string, unknown>
|
|
92
|
+
|
|
93
|
+
const UNSAFE = new Set(['__proto__', 'constructor', 'prototype'])
|
|
94
|
+
|
|
95
|
+
/** A form's fields as a mutation's input, on both sides (research 29 §5): `a.b` nests, `x[]` and repeats are arrays. */
|
|
96
|
+
export function decodeForm(data: FormData): Record<string, unknown> {
|
|
97
|
+
const out: Tree = {}
|
|
98
|
+
for (const [name, value] of data) {
|
|
99
|
+
if (name[0] === '$' || (typeof value !== 'string' && !value.size)) continue // `$fn`; a file input left empty
|
|
100
|
+
const list = name.endsWith('[]')
|
|
101
|
+
const path = (list ? name.slice(0, -2) : name).split('.')
|
|
102
|
+
if (path.some((p) => UNSAFE.has(p))) continue
|
|
103
|
+
let at = out
|
|
104
|
+
for (const p of path.slice(0, -1)) {
|
|
105
|
+
const next = at[p]
|
|
106
|
+
at = (next && typeof next === 'object' ? next : (at[p] = {})) as Tree
|
|
107
|
+
}
|
|
108
|
+
const k = path.at(-1)!
|
|
109
|
+
// Own keys only: a field named `toString` is a field, not the method every object inherits.
|
|
110
|
+
const had = Object.hasOwn(at, k)
|
|
111
|
+
if (list) ((had ? at[k] : (at[k] = [])) as unknown[]).push(value)
|
|
112
|
+
else if (had) at[k] = [at[k]].flat().concat(value)
|
|
113
|
+
else at[k] = value
|
|
114
|
+
}
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- keys -------------------------------------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/** JSON with sorted object keys: the same input gives the same key on both sides (no input: `''`; JSON sends null). */
|
|
121
|
+
export function stable(v: unknown): string {
|
|
122
|
+
if (v == null) return ''
|
|
123
|
+
return JSON.stringify(v, (_, x: Record<string, unknown>) =>
|
|
124
|
+
x && typeof x === 'object' && !Array.isArray(x)
|
|
125
|
+
? Object.fromEntries(
|
|
126
|
+
Object.keys(x)
|
|
127
|
+
.sort()
|
|
128
|
+
.map((k) => [k, x[k]]),
|
|
129
|
+
)
|
|
130
|
+
: x,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The cache key of one call: the function's id and its input in a stable order. */
|
|
135
|
+
export const keyOf = (id: string, input: unknown) => `${id}:${stable(input)}`
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { DEV, effect, getOwner, onCleanup, resource, untrack, type Resource } from '@weft-js/core'
|
|
2
|
+
import { fill, mask } from '@weft-js/core/shape'
|
|
3
|
+
import { entries, entry, fresh, lastSeen, load, predicted } from './cache'
|
|
4
|
+
import { rt, runtime, server, skip, net } from './foundation'
|
|
5
|
+
import { liveApi } from './live-api'
|
|
6
|
+
import { keyOf, StartError } from './primitives'
|
|
7
|
+
import { track } from './transport'
|
|
8
|
+
import type { Fn, LiveOptions, LiveQuery } from './types'
|
|
9
|
+
|
|
10
|
+
export function fetchQuery(fn: Fn, input: unknown): Promise<unknown> {
|
|
11
|
+
const scope = server()
|
|
12
|
+
if (scope) return runtime.exec!.fetchQuery(scope, fn, input).then((e) => e.v)
|
|
13
|
+
if (runtime.exec && !net())
|
|
14
|
+
return runtime.exec
|
|
15
|
+
.fetchQuery(runtime.exec.createScope(new Request('http://localhost/')), fn, input)
|
|
16
|
+
.then((e) => e.v)
|
|
17
|
+
const e = entry(fn, input)
|
|
18
|
+
return untrack(() => fresh(e)) ? Promise.resolve(untrack(() => e.value.value)) : load(e)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function use(fn: Fn, input: unknown): Resource<unknown> {
|
|
22
|
+
if (!getOwner())
|
|
23
|
+
throw DEV
|
|
24
|
+
? new StartError(
|
|
25
|
+
'Q001',
|
|
26
|
+
`\`${fn.$id}()\` (a live query) was called outside a component or scope`,
|
|
27
|
+
`\`await ${name(fn)}.fetch(input)\` in \`load\`, handlers and server code`,
|
|
28
|
+
)
|
|
29
|
+
: new StartError('Q001', fn.$id)
|
|
30
|
+
const read = () => (typeof input === 'function' ? (input as () => unknown)() : input)
|
|
31
|
+
return resource(
|
|
32
|
+
() => {
|
|
33
|
+
const arg = read()
|
|
34
|
+
if (arg === skip) return undefined
|
|
35
|
+
const scope = server()
|
|
36
|
+
if (scope) return runtime.exec!.readQuery(scope, fn, arg)
|
|
37
|
+
const e = entry(fn, arg)
|
|
38
|
+
e.readers++
|
|
39
|
+
onCleanup(() => void e.readers--)
|
|
40
|
+
const v = e.value.value // tracked: a new base or layer re-runs this, and the resource settles to it in place
|
|
41
|
+
if (fresh(e)) return v
|
|
42
|
+
if (e.has.value && predicted(e)) {
|
|
43
|
+
e.held = true // the prediction shows now, and a read waits for the calls behind it
|
|
44
|
+
return v
|
|
45
|
+
}
|
|
46
|
+
return e.has.value ? load(e).catch(() => untrack(() => e.value.value)) : load(e) // a failed refetch leaves what's on screen
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
data: false,
|
|
50
|
+
shape: {
|
|
51
|
+
of: fn,
|
|
52
|
+
read() {
|
|
53
|
+
const arg = read()
|
|
54
|
+
return arg === skip ? undefined : shapeOf(fn, arg)
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Research 31 §2: this entry's value or the query's last seen, masked; else the declared shape. */
|
|
62
|
+
export function shapeOf(fn: Fn, input: unknown): unknown {
|
|
63
|
+
const limit: number | undefined = (input as { limit?: number } | undefined)?.limit
|
|
64
|
+
const here = entries.get(keyOf(fn.$id, input))
|
|
65
|
+
const e = server()
|
|
66
|
+
? undefined
|
|
67
|
+
: here && untrack(() => here.has.value)
|
|
68
|
+
? here
|
|
69
|
+
: lastSeen.get(fn.$id)
|
|
70
|
+
if (e)
|
|
71
|
+
return mask(
|
|
72
|
+
untrack(() => e.value.value),
|
|
73
|
+
limit,
|
|
74
|
+
)
|
|
75
|
+
const tree = runtime.exec ? runtime.exec.shapeOf(fn) : fn.def.shape
|
|
76
|
+
return tree === undefined ? undefined : fill(tree, limit)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `q()` plus a subscription: its tags come from the server, and a poke is a local `cache.invalidate`. */
|
|
80
|
+
export function liveQuery(fn: Fn, input: unknown, options?: LiveOptions): LiveQuery<unknown> {
|
|
81
|
+
const r = use(fn, input)
|
|
82
|
+
const scope = server()
|
|
83
|
+
if (scope) scope.live = true
|
|
84
|
+
else
|
|
85
|
+
effect(
|
|
86
|
+
() => {
|
|
87
|
+
const arg = typeof input === 'function' ? (input as () => unknown)() : input
|
|
88
|
+
if (arg === skip) return
|
|
89
|
+
let gone = false,
|
|
90
|
+
stop: (() => void) | undefined
|
|
91
|
+
const held = import('../live').then((m) => {
|
|
92
|
+
m.use(liveApi)
|
|
93
|
+
if (!gone) stop = m.hold(fn, arg, options)
|
|
94
|
+
})
|
|
95
|
+
void track(held)
|
|
96
|
+
onCleanup(() => {
|
|
97
|
+
gone = true
|
|
98
|
+
stop?.()
|
|
99
|
+
})
|
|
100
|
+
},
|
|
101
|
+
() => {},
|
|
102
|
+
)
|
|
103
|
+
return Object.assign(r, { live: rt.status, liveMode: rt.mode })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const name = (fn: Fn) => fn.$id.replace(/\..*$/, '')
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type * as Exec from '../exec'
|
|
2
|
+
import type { Scope } from '../exec'
|
|
3
|
+
import { fns, net, runtime } from './foundation'
|
|
4
|
+
import { FnError, isRedirect, stable, StartError } from './primitives'
|
|
5
|
+
import type { Answer, Fn, NetControl, Transport } from './types'
|
|
6
|
+
|
|
7
|
+
export function transport(): Transport {
|
|
8
|
+
if (runtime.transport) return runtime.transport
|
|
9
|
+
if (net()) return inProcess
|
|
10
|
+
return http
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const answer = async (res: Response): Promise<Answer> => {
|
|
14
|
+
const body = await res
|
|
15
|
+
.json()
|
|
16
|
+
.catch(() => ({ e: { message: `server function answered ${res.status}` } }))
|
|
17
|
+
if (body.e) throw new FnError(body.e, res.status)
|
|
18
|
+
return body
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The browser's: GET for queries, POST + JSON + `x-weft` for mutations (research 27 §3). */
|
|
22
|
+
export const http: Transport = {
|
|
23
|
+
query: (id, input) =>
|
|
24
|
+
fetch(`/_weft/fn/${id}${input == null ? '' : `?i=${encodeURIComponent(stable(input))}`}`, {
|
|
25
|
+
headers: { accept: 'application/json' },
|
|
26
|
+
}).then(answer),
|
|
27
|
+
mutate(id, input, f, form) {
|
|
28
|
+
const headers: Record<string, string> = {
|
|
29
|
+
accept: 'application/json',
|
|
30
|
+
'x-weft': '1',
|
|
31
|
+
...(runtime.conn && { 'x-weft-conn': runtime.conn }),
|
|
32
|
+
}
|
|
33
|
+
if (form instanceof FormData) form.set('$r', JSON.stringify(f))
|
|
34
|
+
else headers['content-type'] = 'application/json'
|
|
35
|
+
return fetch(`/_weft/fn/${id}`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers,
|
|
38
|
+
body:
|
|
39
|
+
form instanceof FormData ? form : JSON.stringify({ i: input, f, ...(form && { c: 1 }) }),
|
|
40
|
+
}).then(answer)
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @weft-js/core/test's fake transport: the server's executor in process, with the controls @weft-js/core/test's `net` sets (latency,
|
|
46
|
+
* failures, replaced functions, offline), and a JSON round trip both ways so values arrive as they would over HTTP.
|
|
47
|
+
*/
|
|
48
|
+
export const fakeScope = (exec: typeof Exec, n: NetControl, request: Request) =>
|
|
49
|
+
exec.createScope(request, {
|
|
50
|
+
context: () => n.ctx ?? {},
|
|
51
|
+
handlers: n.handlers,
|
|
52
|
+
dev: true,
|
|
53
|
+
extensions: n.extensions,
|
|
54
|
+
onDiagnostic: (d) =>
|
|
55
|
+
console.warn(`weft: ${d.message.startsWith(d.code) ? '' : d.code + ' '}${d.message}`),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
type Call = (exec: typeof Exec, scope: Scope, fn: Fn) => Promise<Answer>
|
|
59
|
+
|
|
60
|
+
const inProcess: Transport = {
|
|
61
|
+
query: (id, input) =>
|
|
62
|
+
fake(id, 'query', input, (exec, scope, fn) =>
|
|
63
|
+
exec.fetchQuery(scope, fn, input).then((e) => ({ v: e.v, t: e.tags })),
|
|
64
|
+
),
|
|
65
|
+
mutate: (id, input, f, form) =>
|
|
66
|
+
fake(id, 'mutation', input, (exec, scope, fn) => exec.runMutation(scope, fn, input, f, !!form)),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fake(id: string, kind: string, input: unknown, call: Call): Promise<Answer> {
|
|
70
|
+
return track(fakeCall(net()!, id, kind, input, call))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `net.settle()` waits for `p`. */
|
|
74
|
+
export function track<T>(p: Promise<T>): Promise<T> {
|
|
75
|
+
const n = net()
|
|
76
|
+
if (!n) return p
|
|
77
|
+
n.inflight.add(p)
|
|
78
|
+
const over = () => void n.inflight.delete(p)
|
|
79
|
+
p.then(over, over)
|
|
80
|
+
return p
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function fakeCall(
|
|
84
|
+
n: NetControl,
|
|
85
|
+
id: string,
|
|
86
|
+
kind: string,
|
|
87
|
+
input: unknown,
|
|
88
|
+
call: Call,
|
|
89
|
+
): Promise<Answer> {
|
|
90
|
+
n.log.push({ id, kind, input })
|
|
91
|
+
const exec = (runtime.exec ??= await import('../exec'))
|
|
92
|
+
if (n.latency)
|
|
93
|
+
await new Promise((ok) => {
|
|
94
|
+
setTimeout(ok, n.latency)
|
|
95
|
+
})
|
|
96
|
+
if (!n.online())
|
|
97
|
+
throw Object.assign(new Error(`offline: ${id} wasn't sent`), { name: 'OfflineError' })
|
|
98
|
+
const fails = n.fails.get(id)
|
|
99
|
+
if (fails?.length) throw fails.shift()
|
|
100
|
+
const fn = fns.get(id)
|
|
101
|
+
if (!fn) throw new FnError({ code: 'Q009', message: `Q009: no server function \`${id}\`` }, 404)
|
|
102
|
+
const scope = fakeScope(
|
|
103
|
+
exec,
|
|
104
|
+
n,
|
|
105
|
+
new Request(`http://localhost/_weft/fn/${id}`, {
|
|
106
|
+
method: kind === 'query' ? 'GET' : 'POST',
|
|
107
|
+
headers: runtime.conn ? { 'x-weft-conn': runtime.conn } : {},
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(JSON.stringify(await n.als.run(scope, () => call(exec, scope, fn))))
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (isRedirect(e)) return { r: e.href }
|
|
114
|
+
throw e instanceof StartError
|
|
115
|
+
? new FnError(
|
|
116
|
+
{ code: e.code, message: e.message, issues: e.issues, data: e.data },
|
|
117
|
+
e.status ?? 500,
|
|
118
|
+
)
|
|
119
|
+
: e
|
|
120
|
+
}
|
|
121
|
+
}
|