@weft-js/start 0.1.1 → 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 +38 -8
  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 +120 -492
  32. package/src/client.tsx +9 -2
  33. package/src/contract/describe.mjs +231 -0
  34. package/src/contract.mjs +144 -157
  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 +90 -166
  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 +112 -37
  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 -178
  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 -971
  159. package/src/shape.mjs +30 -6
  160. package/src/share/display.ts +50 -0
  161. package/src/share/network.ts +119 -0
  162. package/src/share/receive.ts +70 -0
  163. package/src/share/state.ts +30 -0
  164. package/src/share/tab-queue.ts +28 -0
  165. package/src/share-server.ts +128 -43
  166. package/src/share.ts +103 -209
  167. package/src/sheets.mjs +30 -18
  168. package/src/sidecar/build.mjs +170 -0
  169. package/src/sidecar/cargo.mjs +164 -0
  170. package/src/sidecar/common.mjs +17 -0
  171. package/src/sidecar/dev-build.mjs +91 -0
  172. package/src/sidecar/dev-watch.mjs +88 -0
  173. package/src/sidecar/manifest.mjs +19 -0
  174. package/src/sidecar/process.mjs +58 -0
  175. package/src/sidecar/refresh.mjs +19 -0
  176. package/src/sidecar.mjs +75 -416
  177. package/src/socket/check.ts +50 -0
  178. package/src/socket/deliver.ts +16 -0
  179. package/src/socket/publish.ts +56 -0
  180. package/src/socket/rooms.ts +41 -0
  181. package/src/socket/route.ts +79 -0
  182. package/src/socket/shared.ts +26 -0
  183. package/src/socket/types.ts +48 -0
  184. package/src/socket.ts +205 -194
  185. package/src/stream.ts +10 -1
  186. package/src/verify/app.ts +39 -0
  187. package/src/verify/cleanup.ts +27 -0
  188. package/src/verify/database.ts +11 -0
  189. package/src/verify/live.ts +63 -0
  190. package/src/verify/notify.ts +35 -0
  191. package/src/verify/skip.ts +19 -0
  192. package/src/verify/support.ts +149 -0
  193. package/src/verify/timing.ts +15 -0
  194. package/src/verify/trigger.ts +14 -0
  195. package/src/verify.ts +77 -176
  196. package/src/view.tsx +96 -11
  197. package/src/vite/asset-bytes.mjs +95 -0
  198. package/src/vite/assets.mjs +117 -0
  199. package/src/vite/config-hooks.mjs +166 -0
  200. package/src/vite/dev-hooks.mjs +103 -0
  201. package/src/vite/dev.mjs +102 -0
  202. package/src/vite/entries.mjs +145 -0
  203. package/src/vite/module-hooks.mjs +288 -0
  204. package/src/vite/route-modules.mjs +289 -0
  205. package/src/vite/server-functions.mjs +222 -0
  206. package/src/vite/shape-entry-css.mjs +187 -0
  207. package/src/vite/shape-override.mjs +226 -0
  208. package/src/vite/shared.mjs +51 -0
  209. package/src/vite/sheets.mjs +92 -0
  210. package/src/vite/state.mjs +89 -0
  211. package/src/vite/syntax.mjs +139 -0
  212. package/src/vite.mjs +152 -853
  213. package/src/capture.d.mts +0 -1
@@ -0,0 +1,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
+ }