@fabrika/app 0.3.3 → 0.4.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.
Files changed (116) hide show
  1. package/README.md +78 -90
  2. package/package.json +77 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +370 -0
  5. package/skill/references/dev.md +90 -0
  6. package/skill/references/endpoint.md +640 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +746 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +308 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +331 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1932 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +444 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +161 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +824 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +120 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1095 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +760 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +281 -0
  90. package/src/react/index.ts +34 -0
  91. package/src/react/provider.tsx +179 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/testing.ts +144 -0
  94. package/src/react/types.ts +40 -0
  95. package/src/rpc/dispatcher.ts +4 -19
  96. package/src/service/define.ts +121 -0
  97. package/src/service/index.ts +21 -0
  98. package/src/service/serve.ts +99 -0
  99. package/src/testing/caller.ts +123 -0
  100. package/src/testing/challenge.ts +10 -0
  101. package/src/testing/dev.ts +99 -0
  102. package/src/testing/endpoint-access.ts +809 -0
  103. package/src/testing/index.ts +36 -0
  104. package/src/testing/isolation.ts +25 -0
  105. package/src/testing/kit.ts +585 -0
  106. package/src/testing/membership.ts +797 -0
  107. package/src/testing/policy-isolation.ts +827 -0
  108. package/src/testing/preload.ts +6 -0
  109. package/src/testing/proxy.ts +87 -0
  110. package/src/testing/runner.ts +61 -0
  111. package/src/testing/status.ts +19 -0
  112. package/src/undumpable.ts +42 -0
  113. package/src/app.ts +0 -76
  114. package/src/middleware.ts +0 -38
  115. package/src/router.ts +0 -149
  116. package/src/testing.ts +0 -119
@@ -0,0 +1,179 @@
1
+ // `FabrikaProvider`: the page's clients, the cache of their answers, and the caller document of the SPA mount. The
2
+ // hooks take a procedure of a client as the typed name of a call; the provider runs it on the client it holds for
3
+ // that mount, so a test hands in a client over the test kit and the page's own module-level client stays as it is.
4
+
5
+ import { createContext, type ReactNode, useContext, useState } from 'react'
6
+ import { z } from 'zod/v4'
7
+ import { type PageCaller, parsePageCaller } from '../client/caller'
8
+ import { attachmentAt, type ClientNode, clientNodeOf, downloadFile } from '../client/contract'
9
+ import { RpcError, type RpcFetch } from '../client/transport'
10
+ import { joinPath, PAGE_CALLER_FILE } from '../contract/defaults'
11
+ import { type FabrikaError, toFabrikaError } from './errors'
12
+ import { QueryStore, type StoreTarget } from './store'
13
+
14
+ export type CallerState =
15
+ | { readonly status: 'loading' }
16
+ | { readonly status: 'ready'; readonly caller: PageCaller }
17
+ | { readonly status: 'error'; readonly error: FabrikaError }
18
+
19
+ /** The caller document, read once per provider when a view first asks for it. */
20
+ export class CallerSource {
21
+ state: CallerState = { status: 'loading' }
22
+ #started = false
23
+ readonly #listeners = new Set<() => void>()
24
+
25
+ constructor(private readonly url: string, private readonly fetch: RpcFetch) {}
26
+
27
+ subscribe(listener: () => void): () => void {
28
+ this.#listeners.add(listener)
29
+ if (!this.#started) {
30
+ this.#started = true
31
+ void this.#load()
32
+ }
33
+ return () => this.#listeners.delete(listener)
34
+ }
35
+
36
+ async #load(): Promise<void> {
37
+ this.state = await this.#read()
38
+ for (const listener of [...this.#listeners]) listener()
39
+ }
40
+
41
+ async #read(): Promise<CallerState> {
42
+ try {
43
+ const response = await this.fetch(this.url, { credentials: 'same-origin' })
44
+ if (!response.ok) {
45
+ return {
46
+ status: 'error',
47
+ error: toFabrikaError(new RpcError({ type: 'caller', message: `HTTP ${response.status}`, httpStatus: response.status })),
48
+ }
49
+ }
50
+ return { status: 'ready', caller: parsePageCaller(await response.json()) }
51
+ } catch (error) {
52
+ return { status: 'error', error: toFabrikaError(error) }
53
+ }
54
+ }
55
+ }
56
+
57
+ export interface Fabrika {
58
+ readonly store: QueryStore
59
+ readonly caller: CallerSource
60
+ /** The call of `procedure` on the provider's client of its mount. */
61
+ target(procedure: unknown, hook: string): ResolvedProcedure
62
+ /** The download of attachment `procedure` on the provider's client of its mount. */
63
+ attachment(procedure: unknown, hook: string): (input: unknown) => Promise<File>
64
+ }
65
+
66
+ export interface ResolvedProcedure extends StoreTarget {
67
+ readonly node: ClientNode
68
+ }
69
+
70
+ const FabrikaContext = createContext<Fabrika | null>(null)
71
+
72
+ /** The `{ path, code, message }` issues of a refused input, as the application answers them. */
73
+ function issuesOf(error: z.core.$ZodError): readonly { readonly path: string; readonly code: string; readonly message: string }[] {
74
+ return error.issues.map((issue) => ({ path: issue.path.map(String).join('.'), code: issue.code, message: issue.message }))
75
+ }
76
+
77
+ /** Checks the input with the procedure's own schema first: a refused input is the application's 400, unsent. */
78
+ function validated(node: ClientNode, input: unknown): unknown {
79
+ const schema = node.procedure?.input ?? null
80
+ if (schema === null) return input
81
+ const parsed = z.safeParse(schema, input ?? {})
82
+ if (parsed.success) return input
83
+ const issues = issuesOf(parsed.error)
84
+ const first = issues[0]
85
+ throw new RpcError({
86
+ type: 'validation',
87
+ message: first === undefined ? 'Invalid input' : `${first.path || 'input'}: ${first.message}`,
88
+ issues,
89
+ httpStatus: 400,
90
+ })
91
+ }
92
+
93
+ function idOf(node: ClientNode): string {
94
+ return node.path === '' ? node.mount : `${node.mount}.${node.path}`
95
+ }
96
+
97
+ function procedureAt(client: object, path: string): unknown {
98
+ let value: unknown = client
99
+ for (const segment of path.split('.')) {
100
+ value = typeof value === 'object' && value !== null ? Reflect.get(value, segment) : undefined
101
+ }
102
+ return value
103
+ }
104
+
105
+ function fabrika(clients: readonly object[], spa: string, fetch: RpcFetch): Fabrika {
106
+ const byMount = new Map<string, object>()
107
+ for (const client of clients) {
108
+ const node = clientNodeOf(client)
109
+ if (node === null || node.path !== '') throw new Error('FabrikaProvider takes the clients createClient made')
110
+ if (byMount.has(node.mount)) throw new Error(`FabrikaProvider holds two clients of mount '${node.mount}'`)
111
+ byMount.set(node.mount, client)
112
+ }
113
+ const clientOf = (mount: string, hook: string): object => {
114
+ const client = byMount.get(mount)
115
+ if (client === undefined) throw new Error(`${hook}: the FabrikaProvider holds no client of mount '${mount}'`)
116
+ return client
117
+ }
118
+ return {
119
+ store: new QueryStore(toFabrikaError),
120
+ caller: new CallerSource(joinPath(spa, PAGE_CALLER_FILE), fetch),
121
+ attachment(procedure, hook) {
122
+ const at = attachmentAt(procedure)
123
+ if (at === null) throw new Error(`${hook} takes an attachment procedure of a client createClient made`)
124
+ const own = procedureAt(clientOf(at.mount, hook), at.path)
125
+ if (attachmentAt(own) === null || typeof own !== 'object' || own === null) {
126
+ throw new Error(`${hook}: '${at.mount}.${at.path}' is not an attachment procedure of the provider's client`)
127
+ }
128
+ return (input) => downloadFile(own, input)
129
+ },
130
+ target(procedure, hook) {
131
+ const node = clientNodeOf(procedure)
132
+ if (node?.procedure == null) throw new Error(`${hook} takes a procedure of a client createClient made`)
133
+ const call = procedureAt(clientOf(node.mount, hook), node.path)
134
+ if (typeof call !== 'function') throw new Error(`${hook}: '${idOf(node)}' is not a procedure of the provider's client`)
135
+ return {
136
+ id: idOf(node),
137
+ node,
138
+ run: async (input) => {
139
+ const answer: unknown = await Reflect.apply(call, undefined, [validated(node, input)])
140
+ return answer
141
+ },
142
+ }
143
+ },
144
+ }
145
+ }
146
+
147
+ export interface FabrikaProviderProps {
148
+ /** The clients of the mounts the page calls, as `createClient` made them; read at the first render. */
149
+ readonly clients: readonly object[]
150
+ /** The prefix of the SPA mount the page is served from: the caller document is `<spa>/__fabrika-me.json`. */
151
+ readonly spa?: string
152
+ /** What the caller document is fetched with; the global `fetch` by default (a test passes the kit's). */
153
+ readonly fetch?: RpcFetch
154
+ readonly children?: ReactNode
155
+ }
156
+
157
+ export function FabrikaProvider({ clients, spa = '/', fetch: fetchCaller, children }: FabrikaProviderProps): ReactNode {
158
+ const [value] = useState(() => fabrika(clients, spa, fetchCaller ?? ((input, init) => fetch(input, init))))
159
+ return <FabrikaContext.Provider value={value}>{children}</FabrikaContext.Provider>
160
+ }
161
+
162
+ export function useFabrika(hook: string): Fabrika {
163
+ const value = useContext(FabrikaContext)
164
+ if (value === null) throw new Error(`${hook} is used outside a FabrikaProvider`)
165
+ return value
166
+ }
167
+
168
+ /** The prefix a mutation invalidates by default: the group it lies in (`api.deals` for `api.deals.create`). */
169
+ export function parentOf(node: ClientNode): string {
170
+ const segments = node.path.split('.')
171
+ return segments.length <= 1 ? node.mount : `${node.mount}.${segments.slice(0, -1).join('.')}`
172
+ }
173
+
174
+ /** The prefix of a procedure or a group, for `invalidate`. */
175
+ export function prefixOf(target: unknown): string {
176
+ const node = clientNodeOf(target)
177
+ if (node === null) throw new Error('invalidate takes a procedure or a group of a client createClient made')
178
+ return idOf(node)
179
+ }
@@ -0,0 +1,215 @@
1
+ // The provider's cache, without React: one entry per procedure and input (and one per paged list), each with the
2
+ // pages it holds, a snapshot the hooks subscribe to, and a sequence number so a slower, older answer never overwrites
3
+ // a newer one. `invalidate` refetches the entries a view shows and drops the others.
4
+
5
+ import type { FabrikaError } from './errors'
6
+
7
+ /** A procedure as the store calls it: its id (`<mount>.<path>`) and the call of the provider's client. */
8
+ export interface StoreTarget {
9
+ readonly id: string
10
+ readonly run: (input: unknown) => Promise<unknown>
11
+ }
12
+
13
+ export type QueryStatus = 'loading' | 'success' | 'error'
14
+
15
+ export interface QuerySnapshot {
16
+ readonly status: QueryStatus
17
+ readonly data: unknown
18
+ readonly error: FabrikaError | null
19
+ /** A request of this entry is in flight: the first load, a refetch or the next page. */
20
+ readonly fetching: boolean
21
+ }
22
+
23
+ export interface PagesSnapshot {
24
+ readonly status: QueryStatus
25
+ readonly items: readonly unknown[]
26
+ readonly next: string | null
27
+ readonly error: FabrikaError | null
28
+ readonly fetching: boolean
29
+ readonly loadingMore: boolean
30
+ }
31
+
32
+ /** One page of a cursor list, as every list procedure of the paved road answers it. */
33
+ export interface Page {
34
+ readonly items: readonly unknown[]
35
+ readonly next: string | null
36
+ }
37
+
38
+ type Listener = () => void
39
+
40
+ /** JSON with sorted keys, so two inputs that are the same value are the same key. */
41
+ export function stableKey(value: unknown): string {
42
+ return JSON.stringify(value, (_key, field: unknown) => {
43
+ if (typeof field !== 'object' || field === null || Array.isArray(field)) return field
44
+ return Object.fromEntries(Object.entries(field).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)))
45
+ }) ?? 'null'
46
+ }
47
+
48
+ /** `<mount>.<path>` lies under `prefix` when it is the prefix itself or one of its descendants. */
49
+ export function under(id: string, prefix: string): boolean {
50
+ return id === prefix || id.startsWith(`${prefix}.`)
51
+ }
52
+
53
+ abstract class Entry<S> {
54
+ snapshot: S
55
+ protected sequence = 0
56
+ readonly #listeners = new Set<Listener>()
57
+
58
+ constructor(readonly target: StoreTarget, readonly input: unknown, initial: S) {
59
+ this.snapshot = initial
60
+ }
61
+
62
+ get watched(): boolean {
63
+ return this.#listeners.size > 0
64
+ }
65
+
66
+ subscribe(listener: Listener): () => void {
67
+ this.#listeners.add(listener)
68
+ return () => this.#listeners.delete(listener)
69
+ }
70
+
71
+ protected publish(snapshot: S): void {
72
+ this.snapshot = snapshot
73
+ for (const listener of [...this.#listeners]) listener()
74
+ }
75
+
76
+ abstract load(): Promise<void>
77
+ }
78
+
79
+ export class QueryEntry extends Entry<QuerySnapshot> {
80
+ constructor(target: StoreTarget, input: unknown, private readonly mapError: (error: unknown) => FabrikaError) {
81
+ super(target, input, { status: 'loading', data: undefined, error: null, fetching: false })
82
+ }
83
+
84
+ async load(): Promise<void> {
85
+ const sequence = ++this.sequence
86
+ this.publish({ ...this.snapshot, fetching: true })
87
+ try {
88
+ const data = await this.target.run(this.input)
89
+ if (sequence !== this.sequence) return
90
+ this.publish({ status: 'success', data, error: null, fetching: false })
91
+ } catch (error) {
92
+ if (sequence !== this.sequence) return
93
+ this.publish({ ...this.snapshot, status: 'error', error: this.mapError(error), fetching: false })
94
+ }
95
+ }
96
+ }
97
+
98
+ /** `null` when the answer is no page: a hook over a procedure that does not page reports it instead of looping. */
99
+ function pageOf(value: unknown): Page | null {
100
+ if (typeof value !== 'object' || value === null) return null
101
+ const items: unknown = Reflect.get(value, 'items')
102
+ const next: unknown = Reflect.get(value, 'next')
103
+ return Array.isArray(items) && (typeof next === 'string' || next === null) ? { items, next } : null
104
+ }
105
+
106
+ const NOT_A_PAGE: FabrikaError = { kind: 'other', message: 'the procedure answered no { items, next } page', status: 0 }
107
+
108
+ export class PagesEntry extends Entry<PagesSnapshot> {
109
+ /** How many pages the view has asked for; a refetch loads as many again. `Infinity` follows every cursor. */
110
+ #pages: number
111
+
112
+ constructor(target: StoreTarget, input: unknown, pages: number, private readonly mapError: (error: unknown) => FabrikaError) {
113
+ super(target, input, { status: 'loading', items: [], next: null, error: null, fetching: false, loadingMore: false })
114
+ this.#pages = pages
115
+ }
116
+
117
+ #request(after: string | null): Promise<unknown> {
118
+ const input = typeof this.input === 'object' && this.input !== null ? this.input : {}
119
+ return this.target.run(after === null ? input : { ...input, after })
120
+ }
121
+
122
+ /** Loads the first `#pages` pages again, from the first cursor. */
123
+ async load(): Promise<void> {
124
+ const sequence = ++this.sequence
125
+ this.publish({ ...this.snapshot, fetching: true })
126
+ try {
127
+ const items: unknown[] = []
128
+ let next: string | null = null
129
+ let loaded = 0
130
+ do {
131
+ const page = pageOf(await this.#request(next))
132
+ if (sequence !== this.sequence) return
133
+ if (page === null) {
134
+ this.#refuse(NOT_A_PAGE)
135
+ return
136
+ }
137
+ items.push(...page.items)
138
+ next = page.next
139
+ loaded++
140
+ } while (next !== null && loaded < this.#pages)
141
+ this.publish({ status: 'success', items, next, error: null, fetching: false, loadingMore: false })
142
+ } catch (error) {
143
+ if (sequence !== this.sequence) return
144
+ this.#refuse(this.mapError(error))
145
+ }
146
+ }
147
+
148
+ #refuse(error: FabrikaError): void {
149
+ this.publish({ ...this.snapshot, status: 'error', error, fetching: false, loadingMore: false })
150
+ }
151
+
152
+ /** Appends the next page; nothing while a request is in flight or when the list has no more. */
153
+ async loadMore(): Promise<void> {
154
+ const after = this.snapshot.next
155
+ if (after === null || this.snapshot.fetching) return
156
+ const sequence = ++this.sequence
157
+ this.publish({ ...this.snapshot, fetching: true, loadingMore: true })
158
+ try {
159
+ const page = pageOf(await this.#request(after))
160
+ if (sequence !== this.sequence) return
161
+ if (page === null) {
162
+ this.#refuse(NOT_A_PAGE)
163
+ return
164
+ }
165
+ this.#pages++
166
+ this.publish({
167
+ status: 'success',
168
+ items: [...this.snapshot.items, ...page.items],
169
+ next: page.next,
170
+ error: null,
171
+ fetching: false,
172
+ loadingMore: false,
173
+ })
174
+ } catch (error) {
175
+ if (sequence !== this.sequence) return
176
+ // The loaded rows stay; the next `loadMore` asks for the same cursor again.
177
+ this.publish({ ...this.snapshot, status: 'error', error: this.mapError(error), fetching: false, loadingMore: false })
178
+ }
179
+ }
180
+ }
181
+
182
+ export class QueryStore {
183
+ readonly #entries = new Map<string, QueryEntry | PagesEntry>()
184
+
185
+ constructor(private readonly mapError: (error: unknown) => FabrikaError) {}
186
+
187
+ query(target: StoreTarget, input: unknown): QueryEntry {
188
+ const key = `query\u0000${target.id}\u0000${stableKey(input)}`
189
+ const found = this.#entries.get(key)
190
+ if (found instanceof QueryEntry) return found
191
+ const entry = new QueryEntry(target, input, this.mapError)
192
+ this.#entries.set(key, entry)
193
+ return entry
194
+ }
195
+
196
+ pages(target: StoreTarget, input: unknown, pages: number): PagesEntry {
197
+ const key = `pages\u0000${target.id}\u0000${stableKey(input)}\u0000${pages}`
198
+ const found = this.#entries.get(key)
199
+ if (found instanceof PagesEntry) return found
200
+ const entry = new PagesEntry(target, input, pages, this.mapError)
201
+ this.#entries.set(key, entry)
202
+ return entry
203
+ }
204
+
205
+ /** Refetches every entry under one of `prefixes` that a view shows, and forgets the rest, so their next view loads. */
206
+ async invalidate(prefixes: readonly string[]): Promise<void> {
207
+ const loads: Promise<void>[] = []
208
+ for (const [key, entry] of this.#entries) {
209
+ if (!prefixes.some((prefix) => under(entry.target.id, prefix))) continue
210
+ if (entry.watched) loads.push(entry.load())
211
+ else this.#entries.delete(key)
212
+ }
213
+ await Promise.all(loads)
214
+ }
215
+ }
@@ -0,0 +1,144 @@
1
+ // @fabrika/app/react/testing — what a screen test of an SPA needs: happy-dom's document over Bun's own web platform,
2
+ // and typing that React reads. No test-runner dependency; happy-dom's registrator is an optional peer dependency.
3
+
4
+ import { GlobalRegistrator } from '@happy-dom/global-registrator'
5
+ import { act } from 'react'
6
+
7
+ /**
8
+ * The classes and functions of Bun's web platform a screen test keeps: the client, the test kit, a form's challenge and
9
+ * a download run on them, and happy-dom's own differ (a happy-dom `File` of a Bun `Blob` holds `[object Blob]`).
10
+ */
11
+ const BUN_GLOBALS = [
12
+ 'fetch',
13
+ 'Request',
14
+ 'Response',
15
+ 'Headers',
16
+ 'AbortController',
17
+ 'AbortSignal',
18
+ 'URL',
19
+ 'URLSearchParams',
20
+ 'Blob',
21
+ 'File',
22
+ 'FormData',
23
+ 'TextEncoder',
24
+ 'TextDecoder',
25
+ 'ReadableStream',
26
+ ] as const
27
+
28
+ export interface InstalledDom {
29
+ /** Closes the window and removes its globals; call it from the test file's `afterAll`. */
30
+ uninstall(): Promise<void>
31
+ }
32
+
33
+ export interface DomOptions {
34
+ /** The page's address; `http://app.test/` by default. A router needs a real origin. */
35
+ readonly url?: string
36
+ }
37
+
38
+ /**
39
+ * Installs happy-dom's window as the globals, keeping Bun's fetch, abort, URL, blob, file and form classes, with
40
+ * navigation off: a download's link click leaves the page where it is, so a later test of the file still has it. Call
41
+ * it in a module the test file imports first: React reads `window` when it loads.
42
+ */
43
+ export function installDom(options: DomOptions = {}): InstalledDom {
44
+ const kept = BUN_GLOBALS.map((name) => [name, Reflect.get(globalThis, name)] as const)
45
+ GlobalRegistrator.register({
46
+ url: options.url ?? 'http://app.test/',
47
+ settings: {
48
+ navigation: {
49
+ disableMainFrameNavigation: true,
50
+ disableChildFrameNavigation: true,
51
+ disableChildPageNavigation: true,
52
+ disableFallbackToSetURL: true,
53
+ },
54
+ },
55
+ })
56
+ for (const [name, value] of kept) Reflect.set(globalThis, name, value)
57
+ Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true)
58
+ return {
59
+ async uninstall() {
60
+ // A request a screen started may still settle: let it, before its window goes.
61
+ await new Promise((resolve) => setTimeout(resolve, 50))
62
+ await GlobalRegistrator.unregister()
63
+ },
64
+ }
65
+ }
66
+
67
+ type Control = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
68
+
69
+ /**
70
+ * The text a label gives its control: what is hidden from assistive technology (a required marker) left out, and so
71
+ * is a control the label wraps.
72
+ */
73
+ function readText(element: Element): string {
74
+ const copy = element.cloneNode(true)
75
+ if (copy instanceof Element) {
76
+ for (const left of copy.querySelectorAll('[aria-hidden="true"], input, textarea, select')) left.remove()
77
+ }
78
+ return (copy.textContent ?? '').replace(/\s+/g, ' ').trim()
79
+ }
80
+
81
+ /** A control's accessible name: its `aria-label`, the elements `aria-labelledby` names, or its `<label>`. */
82
+ function nameOf(control: Control): string {
83
+ const aria = control.getAttribute('aria-label')
84
+ if (aria !== null) return aria.trim()
85
+ const labelledBy = control.getAttribute('aria-labelledby')
86
+ if (labelledBy !== null) {
87
+ return labelledBy.split(/\s+/).map((id) => {
88
+ const label = control.ownerDocument.getElementById(id)
89
+ return label === null ? '' : readText(label)
90
+ }).join(' ').trim()
91
+ }
92
+ const labels = control.labels === null ? [] : [...control.labels]
93
+ return labels.map(readText).join(' ').trim()
94
+ }
95
+
96
+ const isControl = (element: Element): element is Control =>
97
+ element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement
98
+
99
+ /** The one input, textarea or select whose accessible name is `label`; throws when there is none or more than one. */
100
+ export function controlByLabel(label: string, within: ParentNode = document.body): Control {
101
+ const found = [...within.querySelectorAll('input, textarea, select')].filter(isControl).filter((control) => nameOf(control) === label)
102
+ const [control] = found
103
+ if (control === undefined || found.length > 1) {
104
+ throw new Error(`${found.length === 0 ? 'no' : found.length} control${found.length > 1 ? 's' : ''} labelled '${label}'`)
105
+ }
106
+ return control
107
+ }
108
+
109
+ /** The prototype whose `value` setter React's own value tracking does not see, so the next event reads as a change. */
110
+ function valueSetter(control: Control): ((value: string) => void) | undefined {
111
+ const prototype = control instanceof HTMLTextAreaElement
112
+ ? HTMLTextAreaElement.prototype
113
+ : control instanceof HTMLSelectElement
114
+ ? HTMLSelectElement.prototype
115
+ : HTMLInputElement.prototype
116
+ return Object.getOwnPropertyDescriptor(prototype, 'value')?.set
117
+ }
118
+
119
+ /**
120
+ * Types `value` into the control labelled `label`, as React reads a person typing: it focuses the control, sets the
121
+ * value past React's tracking, and sends `input`, `change` and `keyup`. A select takes the option's value.
122
+ */
123
+ export async function type(label: string, value: string, within?: ParentNode): Promise<void> {
124
+ const control = controlByLabel(label, within)
125
+ await act(async () => {
126
+ control.focus()
127
+ valueSetter(control)?.call(control, value)
128
+ control.dispatchEvent(new Event('input', { bubbles: true }))
129
+ control.dispatchEvent(new Event('change', { bubbles: true }))
130
+ control.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }))
131
+ })
132
+ }
133
+
134
+ /** Waits, letting React render, until the text of `within` holds `text`; throws after `timeoutMs` (2 s by default). */
135
+ export async function waitForText(text: string, options: { readonly within?: Element; readonly timeoutMs?: number } = {}): Promise<void> {
136
+ const within = options.within ?? document.body
137
+ const deadline = Date.now() + (options.timeoutMs ?? 2000)
138
+ while (!(within.textContent ?? '').includes(text)) {
139
+ if (Date.now() > deadline) throw new Error(`the page did not show '${text}' in time`)
140
+ await act(async () => {
141
+ await new Promise((resolve) => setTimeout(resolve, 10))
142
+ })
143
+ }
144
+ }
@@ -0,0 +1,40 @@
1
+ // The types the hooks read off a client procedure: its input as the caller writes it, and its answer as the output
2
+ // schema parses it.
3
+
4
+ import type { z } from 'zod/v4'
5
+ import type { Schema } from '../contract/define'
6
+
7
+ /** A query or mutation of a client that `createClient` built. */
8
+ export type ClientProcedure = ((input: never) => Promise<unknown>) & { readonly output: Schema }
9
+
10
+ /** A group of a client tree (`api.deals`), or the client itself: every procedure under it. */
11
+ export interface ClientGroup {
12
+ readonly [name: string]: unknown
13
+ }
14
+
15
+ /** What `invalidate` takes: one procedure, or a group for every procedure under it. */
16
+ export type InvalidateTarget = ClientProcedure | ClientGroup
17
+
18
+ /** The input as the caller writes it; `undefined` admitted for a procedure that takes none. */
19
+ export type ProcedureInput<F extends ClientProcedure> = F extends (input: infer I) => Promise<unknown> ? I : never
20
+
21
+ /** A procedure by its input `I` and output schema `O`: what the hooks take, so both are inferred from the call. */
22
+ export type TypedProcedure<I, O extends Schema> = ((input: I) => Promise<unknown>) & { readonly output: O }
23
+
24
+ /** An input argument that may be left out when the procedure takes no input. */
25
+ export type InputArgument<I> = undefined extends I ? I | void : I
26
+
27
+ /** The answer, as the procedure's output schema parses it. */
28
+ export type ProcedureResult<F extends ClientProcedure> = z.output<F['output']>
29
+
30
+ /** The answer of a cursor list: `{ items, next }`. */
31
+ export interface PageAnswer<Item> {
32
+ readonly items: readonly Item[]
33
+ readonly next: string | null
34
+ }
35
+
36
+ /** A procedure that pages: input `after`, answer `{ items, next }`. */
37
+ export type PagedProcedure<I, Item> = ((input: I) => Promise<unknown>) & { readonly output: z.core.$ZodType<PageAnswer<Item>> }
38
+
39
+ /** The input of a paged procedure without its cursor: the hook sends `after` itself. */
40
+ export type PageInput<I> = undefined extends I ? Omit<NonNullable<I>, 'after'> | void : Omit<I, 'after'>
@@ -114,9 +114,9 @@ async function dispatchOne(router: AnyRouter, ctx: unknown, call: SingleCall): P
114
114
  }
115
115
 
116
116
  /**
117
- * Dispatch an RPC POST. The `ctx` is already built (and decorated by middleware,
118
- * e.g. `ctx.auth`) by the server before this is called. Handles a single call
119
- * `{ method, input }` or a `{ batch: [...] }`.
117
+ * Dispatch an RPC POST. The `ctx` is already built (with its `ctx.auth`, for example)
118
+ * by the server before this is called. One request is one call,
119
+ * `{ method, input }`.
120
120
  */
121
121
  export async function dispatchRpcRequest(args: { router: AnyRouter; ctx: unknown; request: Request }): Promise<Response> {
122
122
  let rawBody: unknown
@@ -126,25 +126,10 @@ export async function dispatchRpcRequest(args: { router: AnyRouter; ctx: unknown
126
126
  return jsonResponse({ error: { type: 'validation', message: 'Invalid JSON body' } }, 400)
127
127
  }
128
128
 
129
- if (isRecord(rawBody) && Array.isArray(rawBody['batch'])) {
130
- const results: SingleResult[] = []
131
- for (const call of rawBody['batch']) {
132
- results.push((await dispatchOne(args.router, args.ctx, normalizeCall(call))).body)
133
- }
134
- return jsonResponse({ batch: results }, 200)
135
- }
136
-
137
129
  if (!isRecord(rawBody) || typeof rawBody['method'] !== 'string') {
138
- return jsonResponse({ error: { type: 'validation', message: 'Body must be { method, input } or { batch: [...] }' } }, 400)
130
+ return jsonResponse({ error: { type: 'validation', message: 'Body must be { method, input }' } }, 400)
139
131
  }
140
132
 
141
133
  const single = await dispatchOne(args.router, args.ctx, { method: rawBody['method'], input: rawBody['input'] })
142
134
  return jsonResponse(single.body, single.status)
143
135
  }
144
-
145
- function normalizeCall(call: unknown): SingleCall {
146
- if (isRecord(call) && typeof call['method'] === 'string') {
147
- return { method: call['method'], input: call['input'] }
148
- }
149
- return { method: '', input: undefined }
150
- }