@fabrika/app 0.4.2 → 0.4.4
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/package.json +8 -8
- package/skill/SKILL.md +1 -1
- package/skill/references/app-setup.md +18 -11
- package/skill/references/spa.md +2 -1
- package/src/client/error-reporting.ts +209 -0
- package/src/implement/browser-config.ts +7 -4
- package/src/implement/dispatch.ts +9 -1
- package/src/react/provider.tsx +32 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/app",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -93,13 +93,13 @@
|
|
|
93
93
|
"test": "bun test --path-ignore-patterns='src/react/**' && bun test --isolate src/react"
|
|
94
94
|
},
|
|
95
95
|
"dependencies": {
|
|
96
|
-
"@fabrika/auth": "^0.4.
|
|
97
|
-
"@fabrika/auth-core": "^0.4.
|
|
98
|
-
"@fabrika/db": "^0.4.
|
|
99
|
-
"@fabrika/jwks": "^0.4.
|
|
100
|
-
"@fabrika/platform": "^0.4.
|
|
101
|
-
"@fabrika/platform-node": "^0.4.
|
|
102
|
-
"@fabrika/proxy-core": "^0.4.
|
|
96
|
+
"@fabrika/auth": "^0.4.4",
|
|
97
|
+
"@fabrika/auth-core": "^0.4.4",
|
|
98
|
+
"@fabrika/db": "^0.4.4",
|
|
99
|
+
"@fabrika/jwks": "^0.4.4",
|
|
100
|
+
"@fabrika/platform": "^0.4.4",
|
|
101
|
+
"@fabrika/platform-node": "^0.4.4",
|
|
102
|
+
"@fabrika/proxy-core": "^0.4.4",
|
|
103
103
|
"jose": "^5.9.0"
|
|
104
104
|
},
|
|
105
105
|
"peerDependencies": {
|
package/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: fabrika-app
|
|
3
|
-
version: 0.4.
|
|
3
|
+
version: 0.4.4
|
|
4
4
|
description: >
|
|
5
5
|
How to write and change a Fabrika application: src/vocabulary.ts, db/migrations/, src/db/tables.ts, the
|
|
6
6
|
slices' endpoints.ts with their handlers, fabrika.contract.ts, jobs, files, webhooks, share links, connectors
|
|
@@ -19,21 +19,20 @@ the SPA is added with it.
|
|
|
19
19
|
"test": "bun test --isolate"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@fabrika/app": "^0.4.
|
|
23
|
-
"@fabrika/db": "^0.4.
|
|
24
|
-
"@fabrika/provider-zerops": "^0.4.
|
|
25
|
-
"@fabrika/proxy-core": "^0.4.
|
|
26
|
-
"@fabrika/ui": "^0.4.
|
|
27
|
-
"@sentry/browser": "10.69.0",
|
|
22
|
+
"@fabrika/app": "^0.4.4",
|
|
23
|
+
"@fabrika/db": "^0.4.4",
|
|
24
|
+
"@fabrika/provider-zerops": "^0.4.4",
|
|
25
|
+
"@fabrika/proxy-core": "^0.4.4",
|
|
26
|
+
"@fabrika/ui": "^0.4.4",
|
|
28
27
|
"kysely": "0.29.6",
|
|
29
28
|
"react": "^19.0.0",
|
|
30
29
|
"react-dom": "^19.0.0",
|
|
31
30
|
"zod": "^3.25.76"
|
|
32
31
|
},
|
|
33
32
|
"devDependencies": {
|
|
34
|
-
"@fabrika/check": "^0.4.
|
|
35
|
-
"@fabrika/cli": "^0.4.
|
|
36
|
-
"@fabrika/platform": "^0.4.
|
|
33
|
+
"@fabrika/check": "^0.4.4",
|
|
34
|
+
"@fabrika/cli": "^0.4.4",
|
|
35
|
+
"@fabrika/platform": "^0.4.4",
|
|
37
36
|
"@happy-dom/global-registrator": "^20.11.2",
|
|
38
37
|
"@types/bun": "latest",
|
|
39
38
|
"@types/react": "^19.0.0",
|
|
@@ -48,8 +47,9 @@ the SPA is added with it.
|
|
|
48
47
|
Bun still links the workspace copies.
|
|
49
48
|
- `build:web` builds the SPA: add it, `build-web.ts`, `web/`, the contract's `site` mount and the `bun run build:web`
|
|
50
49
|
line of `zerops.yaml` when the application gets an SPA, and not before.
|
|
51
|
-
- `kysely` is there for the generated schema's types.
|
|
52
|
-
the DSN and
|
|
50
|
+
- `kysely` is there for the generated schema's types. The SPA reports its errors to Operations by itself:
|
|
51
|
+
`FabrikaProvider` reads the DSN, release and environment the framework serves at `<prefix>/__fabrika-config.json`
|
|
52
|
+
and sends every uncaught error and unhandled rejection there; add no error-reporting SDK. The same file carries
|
|
53
53
|
`public: { <VARIABLE>: <value> }`: each form challenge's `siteKey`, and the variables the SPA mount lists in
|
|
54
54
|
`c.spa(prefix, { …, public: ['SIGNUP_URL'] })`. A page reads a per-environment public value (a site key, a link
|
|
55
55
|
target) there, never as a build-time constant; it holds no other variable, and the contract refuses a secret in it.
|
|
@@ -338,6 +338,7 @@ const built = await Bun.build({
|
|
|
338
338
|
outdir,
|
|
339
339
|
target: 'browser',
|
|
340
340
|
define: { 'process.env.NODE_ENV': JSON.stringify('production') },
|
|
341
|
+
sourcemap: 'external',
|
|
341
342
|
plugins: [shellSlicePlugin({ root: import.meta.dir })],
|
|
342
343
|
})
|
|
343
344
|
if (!built.success) {
|
|
@@ -357,6 +358,12 @@ await Bun.write(
|
|
|
357
358
|
the rows are passed in.
|
|
358
359
|
- The last step points the page's assets at the SPA's prefix (`/`): the mount answers `index.html` for every path
|
|
359
360
|
under it, so a deep link such as `/tasks/<id>` renders. `define` gives React's production build.
|
|
361
|
+
- `sourcemap: 'external'` writes each script's map beside it, and the page names none. The platform build uploads the
|
|
362
|
+
maps to Operations for the release the page reports, so an error's frames show the original source; the mount
|
|
363
|
+
never serves a `.map` file (404). Keep the maps external: `inline` would ship the source to every visitor.
|
|
364
|
+
- **Errors.** `FabrikaProvider` reports the page's uncaught errors and unhandled rejections to Operations, with the
|
|
365
|
+
release and the environment, through the DSN the mount serves; without a DSN (local development, a test) it reports
|
|
366
|
+
nothing. An application that must not report passes `reportErrors={false}`.
|
|
360
367
|
- Set text through JSX, never `dangerouslySetInnerHTML` or `innerHTML`. Same-origin calls carry the session cookie.
|
|
361
368
|
- **Who is signed in.** Every member SPA serves `<prefix>/__fabrika-me.json` to the person it admitted:
|
|
362
369
|
`{ label, email, actions: { app, scopes } }`. `useCaller()` (`@fabrika/app/react`) answers `{ label, email,
|
package/skill/references/spa.md
CHANGED
|
@@ -96,7 +96,8 @@ if (root !== null) {
|
|
|
96
96
|
- `FabrikaProvider` holds the clients of the mounts the page calls. A hook takes a procedure of a client
|
|
97
97
|
(`api.tasks.get`) as the typed name of the call and runs it on the provider's client of that mount; a test hands the
|
|
98
98
|
provider a client over the test kit instead (below). An SPA mounted under a prefix passes it: `spa="/app"`. Its bare
|
|
99
|
-
prefix (`/app`) redirects to `/app/`, so a link may name either.
|
|
99
|
+
prefix (`/app`) redirects to `/app/`, so a link may name either. It also reports the page's uncaught errors to
|
|
100
|
+
Operations (`reportErrors={false}` turns that off); never add an error-reporting SDK.
|
|
100
101
|
- `createRouter` answers typed paths: `href('task', { id })`, `navigate('project', { orgId, projectId })`, `<Link
|
|
101
102
|
to="task" params={{ id }}>`, and `useRoute()`, the match or `null`. `<Router>` makes every plain `<a href>` under the
|
|
102
103
|
SPA navigate without a reload: the kit's components render links that way. Pass `{ base: '/app' }` for a prefix.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Browser error reporting on the paved road. `FabrikaProvider` reads the SPA mount's browser config and, when it names
|
|
2
|
+
// an Operations DSN, sends every uncaught error and unhandled rejection of the page to that ingest as one Sentry error
|
|
3
|
+
// event. Operations ingests error events only, so nothing else of the page leaves it.
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod/v4'
|
|
6
|
+
import type { RpcFetch } from './transport'
|
|
7
|
+
|
|
8
|
+
const BrowserConfigSchema = z.object({
|
|
9
|
+
dsn: z.string().nullable(),
|
|
10
|
+
release: z.string().nullable(),
|
|
11
|
+
environment: z.string().nullable(),
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
/** One stack frame in Sentry's shape, the file a script URL. */
|
|
15
|
+
export interface BrowserFrame {
|
|
16
|
+
readonly filename: string
|
|
17
|
+
readonly function?: string
|
|
18
|
+
readonly lineno: number
|
|
19
|
+
readonly colno: number
|
|
20
|
+
/** A frame of a script of the page's own origin. */
|
|
21
|
+
readonly in_app: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Where the page reports, read from its browser config. */
|
|
25
|
+
export interface ReportingTarget {
|
|
26
|
+
/** The ingest's envelope endpoint, authenticated by the DSN's public key in its query, as the Sentry SDK sends it. */
|
|
27
|
+
readonly envelopeUrl: string
|
|
28
|
+
readonly release: string | null
|
|
29
|
+
readonly environment: string | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What the reporter listens on and reads the page's address from: `window` in a page. */
|
|
33
|
+
export interface ReportingPage {
|
|
34
|
+
addEventListener(type: 'error' | 'unhandledrejection', listener: (event: Event) => void): void
|
|
35
|
+
removeEventListener(type: 'error' | 'unhandledrejection', listener: (event: Event) => void): void
|
|
36
|
+
readonly location: { readonly origin: string; readonly pathname: string }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PUBLIC_KEY = /^[0-9a-f]{32}$/
|
|
40
|
+
const PROJECT_ID = /^[1-9][0-9]{0,18}$/
|
|
41
|
+
/** A page that keeps failing stops reporting here; Operations rate-limits the whole source, not one page. */
|
|
42
|
+
export const MAX_REPORTS_PER_PAGE = 20
|
|
43
|
+
const MAX_FRAMES = 50
|
|
44
|
+
const MAX_TEXT = 2_048
|
|
45
|
+
const TYPE_NAME = /^[A-Za-z_$][\w$.]{0,127}$/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The envelope endpoint of an ingest DSN `https://<key>@<host>/<project>`, or `null` for any other value. The server's
|
|
49
|
+
* reporter parses the same DSN (`jobs/report.ts`); it is server code, so a page does not import it.
|
|
50
|
+
*/
|
|
51
|
+
export function envelopeUrlOf(dsn: string): string | null {
|
|
52
|
+
let url: URL
|
|
53
|
+
try {
|
|
54
|
+
url = new URL(dsn.trim())
|
|
55
|
+
} catch {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
const project = url.pathname.slice(1)
|
|
59
|
+
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || !PUBLIC_KEY.test(url.username) || url.password !== '' || !PROJECT_ID.test(project)) {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
return `${url.origin}/api/${project}/envelope/?sentry_key=${url.username}&sentry_version=7`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The reporting target the browser config names, or `null` when it names no usable DSN. */
|
|
66
|
+
export function reportingTargetOf(config: unknown): ReportingTarget | null {
|
|
67
|
+
const parsed = BrowserConfigSchema.safeParse(config)
|
|
68
|
+
if (!parsed.success || parsed.data.dsn === null) return null
|
|
69
|
+
const envelopeUrl = envelopeUrlOf(parsed.data.dsn)
|
|
70
|
+
return envelopeUrl === null ? null : { envelopeUrl, release: parsed.data.release, environment: parsed.data.environment }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const cut = (text: string): string => text.length <= MAX_TEXT ? text : `${text.slice(0, MAX_TEXT)}…`
|
|
74
|
+
|
|
75
|
+
const V8_FRAME = /^\s*at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?$/
|
|
76
|
+
const GECKO_FRAME = /^(?:(.*?)@)?(.+?):(\d+):(\d+)$/
|
|
77
|
+
|
|
78
|
+
function parseFrame(line: string, origin: string): BrowserFrame | null {
|
|
79
|
+
const match = /^\s*at /.test(line) ? V8_FRAME.exec(line) : GECKO_FRAME.exec(line)
|
|
80
|
+
const filename = match?.[2]
|
|
81
|
+
if (match === null || filename === undefined || !URL.canParse(filename)) return null
|
|
82
|
+
const name = match[1]?.replace(/^(?:async|new) /, '').trim()
|
|
83
|
+
return {
|
|
84
|
+
filename: cut(filename),
|
|
85
|
+
...(name === undefined || name === '' ? {} : { function: cut(name) }),
|
|
86
|
+
lineno: Number(match[3]),
|
|
87
|
+
colno: Number(match[4]),
|
|
88
|
+
in_app: filename.startsWith(`${origin}/`),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The frames of a V8, SpiderMonkey or JavaScriptCore `stack`, oldest call first as Sentry orders them, the
|
|
94
|
+
* `MAX_FRAMES` nearest the throw kept. A frame whose file is no URL (an `eval`, a native function) is left out.
|
|
95
|
+
*/
|
|
96
|
+
export function parseBrowserStack(stack: string, origin: string): BrowserFrame[] {
|
|
97
|
+
const frames: BrowserFrame[] = []
|
|
98
|
+
for (const line of stack.split('\n')) {
|
|
99
|
+
const frame = parseFrame(line, origin)
|
|
100
|
+
if (frame !== null) frames.push(frame)
|
|
101
|
+
if (frames.length === MAX_FRAMES) break
|
|
102
|
+
}
|
|
103
|
+
return frames.reverse()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type Mechanism = 'onerror' | 'onunhandledrejection'
|
|
107
|
+
|
|
108
|
+
interface ReportedException {
|
|
109
|
+
readonly type: string
|
|
110
|
+
readonly value: string
|
|
111
|
+
readonly mechanism: { readonly type: Mechanism; readonly handled: false }
|
|
112
|
+
readonly stacktrace?: { readonly frames: readonly BrowserFrame[] }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function exceptionOf(thrown: unknown, message: string, mechanism: Mechanism, origin: string): ReportedException {
|
|
116
|
+
if (thrown instanceof Error) {
|
|
117
|
+
const frames = typeof thrown.stack === 'string' ? parseBrowserStack(thrown.stack, origin) : []
|
|
118
|
+
return {
|
|
119
|
+
type: TYPE_NAME.test(thrown.name) ? thrown.name : 'Error',
|
|
120
|
+
value: cut(thrown.message),
|
|
121
|
+
mechanism: { type: mechanism, handled: false },
|
|
122
|
+
...(frames.length === 0 ? {} : { stacktrace: { frames } }),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const value = typeof thrown === 'string' ? thrown : message !== '' ? message : `a ${typeof thrown} was thrown`
|
|
126
|
+
return { type: mechanism === 'onerror' ? 'Error' : 'UnhandledRejection', value: cut(value), mechanism: { type: mechanism, handled: false } }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const hexId = (): string => crypto.randomUUID().replaceAll('-', '')
|
|
130
|
+
|
|
131
|
+
/** The envelope of one error event. The page's address goes without its query and fragment, which may carry a key. */
|
|
132
|
+
export function browserEnvelope(
|
|
133
|
+
exception: ReportedException,
|
|
134
|
+
target: ReportingTarget,
|
|
135
|
+
page: ReportingPage['location'],
|
|
136
|
+
now: Date,
|
|
137
|
+
): string {
|
|
138
|
+
const eventId = hexId()
|
|
139
|
+
const event = {
|
|
140
|
+
event_id: eventId,
|
|
141
|
+
timestamp: now.toISOString(),
|
|
142
|
+
level: 'error',
|
|
143
|
+
platform: 'javascript',
|
|
144
|
+
logger: 'fabrika.browser',
|
|
145
|
+
request: { url: `${page.origin}${page.pathname}` },
|
|
146
|
+
exception: { values: [exception] },
|
|
147
|
+
...(target.release === null ? {} : { release: target.release }),
|
|
148
|
+
...(target.environment === null ? {} : { environment: target.environment }),
|
|
149
|
+
}
|
|
150
|
+
return [JSON.stringify({ event_id: eventId, sent_at: now.toISOString() }), JSON.stringify({ type: 'event' }), JSON.stringify(event), ''].join('\n')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const signatureOf = (exception: ReportedException): string =>
|
|
154
|
+
JSON.stringify([exception.type, exception.value, exception.stacktrace?.frames.at(-1) ?? null])
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Listen for the page's uncaught errors and unhandled rejections and send each to `target`; answers the uninstall.
|
|
158
|
+
* An error equal to the one just sent is not sent again, and the page sends at most `MAX_REPORTS_PER_PAGE`. A failed
|
|
159
|
+
* delivery is dropped: reporting never throws into the page.
|
|
160
|
+
*/
|
|
161
|
+
export function installErrorReporting(page: ReportingPage, target: ReportingTarget, send: RpcFetch): () => void {
|
|
162
|
+
let sent = 0
|
|
163
|
+
let previous = ''
|
|
164
|
+
const report = (exception: ReportedException): void => {
|
|
165
|
+
const signature = signatureOf(exception)
|
|
166
|
+
if (sent >= MAX_REPORTS_PER_PAGE || signature === previous) return
|
|
167
|
+
sent++
|
|
168
|
+
previous = signature
|
|
169
|
+
// A simple request (no custom header, a text body) needs no CORS preflight; `keepalive` outlives a navigation.
|
|
170
|
+
void send(target.envelopeUrl, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { 'content-type': 'text/plain;charset=UTF-8' },
|
|
173
|
+
body: browserEnvelope(exception, target, page.location, new Date()),
|
|
174
|
+
credentials: 'omit',
|
|
175
|
+
keepalive: true,
|
|
176
|
+
}).then((response) => response.body?.cancel(), () => {})
|
|
177
|
+
}
|
|
178
|
+
// Read by name: an `ErrorEvent`'s `error` and `message`, a `PromiseRejectionEvent`'s `reason`, whichever class made them.
|
|
179
|
+
const onError = (event: Event): void => {
|
|
180
|
+
const error: unknown = Reflect.get(event, 'error')
|
|
181
|
+
const message: unknown = Reflect.get(event, 'message')
|
|
182
|
+
report(exceptionOf(error, typeof message === 'string' ? message : '', 'onerror', page.location.origin))
|
|
183
|
+
}
|
|
184
|
+
const onRejection = (event: Event): void => {
|
|
185
|
+
const reason: unknown = Reflect.get(event, 'reason')
|
|
186
|
+
report(exceptionOf(reason, '', 'onunhandledrejection', page.location.origin))
|
|
187
|
+
}
|
|
188
|
+
page.addEventListener('error', onError)
|
|
189
|
+
page.addEventListener('unhandledrejection', onRejection)
|
|
190
|
+
return () => {
|
|
191
|
+
page.removeEventListener('error', onError)
|
|
192
|
+
page.removeEventListener('unhandledrejection', onRejection)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Read the browser config at `configUrl` and install reporting when it names a DSN; answers the uninstall, or `null`
|
|
198
|
+
* when nothing was installed. A missing, refused or unreadable config (local development, a test) installs nothing.
|
|
199
|
+
*/
|
|
200
|
+
export async function startErrorReporting(configUrl: string, fetch: RpcFetch, page: ReportingPage): Promise<(() => void) | null> {
|
|
201
|
+
let target: ReportingTarget | null
|
|
202
|
+
try {
|
|
203
|
+
const response = await fetch(configUrl, { credentials: 'same-origin' })
|
|
204
|
+
target = response.ok ? reportingTargetOf(await response.json()) : null
|
|
205
|
+
} catch {
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
return target === null ? null : installErrorReporting(page, target, fetch)
|
|
209
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// What an SPA mount answers at `<prefix>/__fabrika-config.json`: the browser's Operations settings
|
|
2
|
-
// values the contract names, and nothing else of the environment.
|
|
1
|
+
// What an SPA mount answers at `<prefix>/__fabrika-config.json`: the browser's Operations settings (DSN, release and
|
|
2
|
+
// environment) and the public values the contract names, and nothing else of the environment.
|
|
3
3
|
|
|
4
4
|
import type { ContractArtifact } from '@fabrika/auth-core'
|
|
5
5
|
import type { CapturedEnvironment } from './environment'
|
|
@@ -8,9 +8,11 @@ export interface BrowserConfig {
|
|
|
8
8
|
/** The Operations ingest DSN (a public key), or `null` without one. */
|
|
9
9
|
readonly dsn: string | null
|
|
10
10
|
readonly release: string | null
|
|
11
|
+
/** `FABRIKA_ENVIRONMENT`, which the page's error reports carry, or `null` when unset. */
|
|
12
|
+
readonly environment: string | null
|
|
11
13
|
/**
|
|
12
14
|
* Every form challenge's site key, and the variables the mount lists in `public`, by variable name. Absent when
|
|
13
|
-
* there is none, so the file of an application without them holds the
|
|
15
|
+
* there is none, so the file of an application without them holds the three Operations values only.
|
|
14
16
|
*/
|
|
15
17
|
readonly public?: Readonly<Record<string, string>>
|
|
16
18
|
}
|
|
@@ -48,7 +50,8 @@ export function browserConfigs(
|
|
|
48
50
|
else own[variable] = value
|
|
49
51
|
}
|
|
50
52
|
const served = { ...siteKeys, ...own }
|
|
51
|
-
|
|
53
|
+
const reporting = { ...operations, environment: environment.framework['FABRIKA_ENVIRONMENT']?.trim() || null }
|
|
54
|
+
configs.set(name, Object.freeze(Object.keys(served).length === 0 ? reporting : { ...reporting, public: Object.freeze(served) }))
|
|
52
55
|
}
|
|
53
56
|
return configs
|
|
54
57
|
}
|
|
@@ -300,7 +300,7 @@ export class ContractRuntime {
|
|
|
300
300
|
if (rawRequestTarget(request.url).pathname === bootstrapPath(served.prefix)) {
|
|
301
301
|
return bootstrapResponse()
|
|
302
302
|
}
|
|
303
|
-
return await withBootstrap(await this.#
|
|
303
|
+
return await withBootstrap(await this.#asset(served, request), served.prefix)
|
|
304
304
|
case 'procedure':
|
|
305
305
|
return await this.#procedure(served, request)
|
|
306
306
|
case 'file':
|
|
@@ -345,6 +345,14 @@ export class ContractRuntime {
|
|
|
345
345
|
}
|
|
346
346
|
return jsonResponse(pageCallerOf(caller.principal, caller.perms, this.#parts.actions), 200)
|
|
347
347
|
}
|
|
348
|
+
return await this.#asset(served, request)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** A file of the mount's directory. A source map goes to Operations at build and is never served: it holds the source. */
|
|
352
|
+
async #asset(served: { readonly prefix: string; readonly dir: string }, request: Request): Promise<Response> {
|
|
353
|
+
if (rawRequestTarget(request.url).pathname.endsWith('.map')) {
|
|
354
|
+
throw new NotFoundError('A source map is not served')
|
|
355
|
+
}
|
|
348
356
|
return await this.#assets().serve({ prefix: served.prefix, dir: served.dir }, request)
|
|
349
357
|
}
|
|
350
358
|
|
package/src/react/provider.tsx
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
// `FabrikaProvider`: the page's clients, the cache of their answers,
|
|
2
|
-
// hooks take a procedure of a client as the typed name of a call; the provider runs it on
|
|
3
|
-
// that mount, so a test hands in a client over the test kit and the page's own module-level
|
|
1
|
+
// `FabrikaProvider`: the page's clients, the cache of their answers, the caller document of the SPA mount, and the
|
|
2
|
+
// page's error reporting. The hooks take a procedure of a client as the typed name of a call; the provider runs it on
|
|
3
|
+
// the client it holds for that mount, so a test hands in a client over the test kit and the page's own module-level
|
|
4
|
+
// client stays as it is.
|
|
4
5
|
|
|
5
|
-
import { createContext, type ReactNode, useContext, useState } from 'react'
|
|
6
|
+
import { createContext, type ReactNode, useContext, useEffect, useState } from 'react'
|
|
6
7
|
import { z } from 'zod/v4'
|
|
7
8
|
import { type PageCaller, parsePageCaller } from '../client/caller'
|
|
8
9
|
import { attachmentAt, type ClientNode, clientNodeOf, downloadFile } from '../client/contract'
|
|
10
|
+
import { startErrorReporting } from '../client/error-reporting'
|
|
9
11
|
import { RpcError, type RpcFetch } from '../client/transport'
|
|
10
|
-
import { joinPath, PAGE_CALLER_FILE } from '../contract/defaults'
|
|
12
|
+
import { BROWSER_CONFIG_FILE, joinPath, PAGE_CALLER_FILE } from '../contract/defaults'
|
|
11
13
|
import { type FabrikaError, toFabrikaError } from './errors'
|
|
12
14
|
import { QueryStore, type StoreTarget } from './store'
|
|
13
15
|
|
|
@@ -149,13 +151,35 @@ export interface FabrikaProviderProps {
|
|
|
149
151
|
readonly clients: readonly object[]
|
|
150
152
|
/** The prefix of the SPA mount the page is served from: the caller document is `<spa>/__fabrika-me.json`. */
|
|
151
153
|
readonly spa?: string
|
|
152
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* What the caller document, the browser config and the error reports are fetched with; the global `fetch` by default
|
|
156
|
+
* (a test passes the kit's).
|
|
157
|
+
*/
|
|
153
158
|
readonly fetch?: RpcFetch
|
|
159
|
+
/**
|
|
160
|
+
* Report the page's uncaught errors and unhandled rejections to Operations, through the DSN and release the mount
|
|
161
|
+
* serves at `<spa>/__fabrika-config.json`; `true` by default. Without a DSN nothing is reported.
|
|
162
|
+
*/
|
|
163
|
+
readonly reportErrors?: boolean
|
|
154
164
|
readonly children?: ReactNode
|
|
155
165
|
}
|
|
156
166
|
|
|
157
|
-
export function FabrikaProvider({ clients, spa = '/', fetch:
|
|
158
|
-
const [
|
|
167
|
+
export function FabrikaProvider({ clients, spa = '/', fetch: fetchGiven, reportErrors = true, children }: FabrikaProviderProps): ReactNode {
|
|
168
|
+
const [send] = useState<RpcFetch>(() => fetchGiven ?? ((input, init) => fetch(input, init)))
|
|
169
|
+
const [value] = useState(() => fabrika(clients, spa, send))
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!reportErrors || typeof window === 'undefined') return
|
|
172
|
+
let stopped = false
|
|
173
|
+
let uninstall: (() => void) | null = null
|
|
174
|
+
void startErrorReporting(joinPath(spa, BROWSER_CONFIG_FILE), send, window).then((installed) => {
|
|
175
|
+
if (stopped) installed?.()
|
|
176
|
+
else uninstall = installed
|
|
177
|
+
})
|
|
178
|
+
return () => {
|
|
179
|
+
stopped = true
|
|
180
|
+
uninstall?.()
|
|
181
|
+
}
|
|
182
|
+
}, [reportErrors, spa, send])
|
|
159
183
|
return <FabrikaContext.Provider value={value}>{children}</FabrikaContext.Provider>
|
|
160
184
|
}
|
|
161
185
|
|