@fabrika/app 0.3.3 → 0.4.0
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 +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +309 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +808 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +749 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- package/src/testing.ts +0 -119
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# references/app-setup.md — the repository of an application
|
|
2
|
+
|
|
3
|
+
An application is the root of its own repository: Zerops reads `zerops.yaml` there. In the fabrika-platform
|
|
4
|
+
repository an example is a workspace under `examples/<name>/`, laid out the same way. This file shows an application
|
|
5
|
+
with tables and an SPA; a new one starts without either (`references/start.md`), and each part below that belongs to
|
|
6
|
+
the SPA is added with it.
|
|
7
|
+
|
|
8
|
+
## Packages and compiler
|
|
9
|
+
|
|
10
|
+
```json package.json
|
|
11
|
+
{
|
|
12
|
+
"name": "tasks",
|
|
13
|
+
"private": true,
|
|
14
|
+
"type": "module",
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build:manifest": "fabrika app build --env=prod",
|
|
17
|
+
"build:web": "bun build-web.ts",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"test": "bun test --isolate"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@fabrika/app": "^0.4.0",
|
|
23
|
+
"@fabrika/db": "^0.4.0",
|
|
24
|
+
"@fabrika/provider-zerops": "^0.4.0",
|
|
25
|
+
"@fabrika/proxy-core": "^0.4.0",
|
|
26
|
+
"@fabrika/ui": "^0.4.0",
|
|
27
|
+
"@sentry/browser": "10.69.0",
|
|
28
|
+
"kysely": "0.29.6",
|
|
29
|
+
"react": "^19.0.0",
|
|
30
|
+
"react-dom": "^19.0.0",
|
|
31
|
+
"zod": "^3.25.76"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@fabrika/check": "^0.4.0",
|
|
35
|
+
"@fabrika/cli": "^0.4.0",
|
|
36
|
+
"@fabrika/platform": "^0.4.0",
|
|
37
|
+
"@happy-dom/global-registrator": "^20.11.2",
|
|
38
|
+
"@types/bun": "latest",
|
|
39
|
+
"@types/react": "^19.0.0",
|
|
40
|
+
"@types/react-dom": "^19.0.0",
|
|
41
|
+
"typescript": "^5.9.3"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- The name is not under `@fabrika/*` (that scope is the platform's), and every `@fabrika/*` version is a registry
|
|
47
|
+
range, never `workspace:*`. The checker refuses both (`package-replacement`). Inside the fabrika-platform repository
|
|
48
|
+
Bun still links the workspace copies.
|
|
49
|
+
- `build:web` builds the SPA: add it, `build-web.ts`, `web/`, the contract's `site` mount and the `bun run build:web`
|
|
50
|
+
line of `zerops.yaml` when the application gets an SPA, and not before.
|
|
51
|
+
- `kysely` is there for the generated schema's types. `@sentry/browser` reports the SPA's errors to Operations, from
|
|
52
|
+
the DSN and release the framework serves at `<prefix>/__fabrika-config.json`. The same file carries
|
|
53
|
+
`public: { <VARIABLE>: <value> }`: each form challenge's `siteKey`, and the variables the SPA mount lists in
|
|
54
|
+
`c.spa(prefix, { …, public: ['SIGNUP_URL'] })`. A page reads a per-environment public value (a site key, a link
|
|
55
|
+
target) there, never as a build-time constant; it holds no other variable, and the contract refuses a secret in it.
|
|
56
|
+
`react`, `react-dom` and `@fabrika/ui` (with `@types/react`, `@types/react-dom` and `@happy-dom/global-registrator`
|
|
57
|
+
for the screen tests) are the SPA's: add them with it.
|
|
58
|
+
- After you create a new workspace in a monorepo, run `bun install` at the repository root. Until then every
|
|
59
|
+
`@fabrika/*` import fails and tsc reports a cascade of implicit `any`s that are not your bugs.
|
|
60
|
+
- To try unreleased packages outside the fabrika-platform repository, run `bun scripts/release.ts pack --local
|
|
61
|
+
--artifacts=<dir>` there. It writes the tarballs, `release-manifest.json` and `install.json`. Copy the tarballs into
|
|
62
|
+
the application's `vendor/`, replace every `@fabrika/*` entry of `dependencies` and `devDependencies` with the
|
|
63
|
+
`dependencies` of `install.json`, add its `overrides`, and run `bun install`. Then run the checker as
|
|
64
|
+
`fabrika-check --packed <dir>/release-manifest.json`: without the flag it refuses the tarballs. A deployed
|
|
65
|
+
application never takes this form; the platform build installs from the registry.
|
|
66
|
+
|
|
67
|
+
```json tsconfig.json
|
|
68
|
+
{
|
|
69
|
+
"compilerOptions": {
|
|
70
|
+
"target": "ES2022",
|
|
71
|
+
"module": "ESNext",
|
|
72
|
+
"moduleResolution": "bundler",
|
|
73
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
74
|
+
"jsx": "react-jsx",
|
|
75
|
+
"strict": true,
|
|
76
|
+
"noUncheckedIndexedAccess": true,
|
|
77
|
+
"noImplicitOverride": true,
|
|
78
|
+
"noPropertyAccessFromIndexSignature": true,
|
|
79
|
+
"forceConsistentCasingInFileNames": true,
|
|
80
|
+
"skipLibCheck": true,
|
|
81
|
+
"isolatedModules": true,
|
|
82
|
+
"noEmit": true,
|
|
83
|
+
"types": ["@types/bun"]
|
|
84
|
+
},
|
|
85
|
+
"include": ["src/**/*", "web/**/*", "dev/**/*", "*.ts"]
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The tsconfig stands alone: no `extends` of a file outside the application. `lib` is ES2022, so `toSorted` and other
|
|
90
|
+
newer methods are not there.
|
|
91
|
+
|
|
92
|
+
## The composition
|
|
93
|
+
|
|
94
|
+
```ts src/app-kit.ts
|
|
95
|
+
import { defineApp } from '@fabrika/app/contract'
|
|
96
|
+
import { tables } from './db/tables'
|
|
97
|
+
import { vocabulary } from './vocabulary'
|
|
98
|
+
|
|
99
|
+
export const app = defineApp(vocabulary, tables)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```ts fabrika.contract.ts
|
|
103
|
+
import { app } from './src/app-kit'
|
|
104
|
+
import { comments } from './src/comments/endpoints'
|
|
105
|
+
import { members } from './src/members/endpoints'
|
|
106
|
+
import { priorities } from './src/priorities/endpoints'
|
|
107
|
+
import { projects } from './src/projects/endpoints'
|
|
108
|
+
import { reports } from './src/reports/endpoints'
|
|
109
|
+
import { tasks } from './src/tasks/endpoints'
|
|
110
|
+
|
|
111
|
+
export const contract = app.contract((c) => ({
|
|
112
|
+
api: c.rpc('/api', {
|
|
113
|
+
projects,
|
|
114
|
+
tasks,
|
|
115
|
+
comments,
|
|
116
|
+
reports,
|
|
117
|
+
members,
|
|
118
|
+
priorities,
|
|
119
|
+
}),
|
|
120
|
+
site: c.spa('/', { dir: 'dist/web', access: { member: true } }),
|
|
121
|
+
health: c.health('/healthz'),
|
|
122
|
+
}))
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```ts src/app.ts
|
|
126
|
+
import { serve } from '@fabrika/app/implement'
|
|
127
|
+
import { scoped } from '@fabrika/db'
|
|
128
|
+
import { contract } from '../fabrika.contract'
|
|
129
|
+
import { tables } from './db/tables'
|
|
130
|
+
|
|
131
|
+
export default serve(contract, { database: scoped(tables) })
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- Mounts: `c.rpc(prefix, groups)` once per prefix, `c.spa(prefix, { dir, access })`, `c.health('/healthz')` (required
|
|
135
|
+
on Zerops), `c.form(path, { input, appendTo, then? })` (an anonymous form that appends one row and runs no handler), a
|
|
136
|
+
webhook, a link mount. An SPA's `access` is `{ member: true }`, `{ action, over }`, or `{ public: true }` for a page
|
|
137
|
+
anyone opens without signing in (a landing page, a public form): the approver sees it as "for anyone". A public SPA
|
|
138
|
+
at `/` answers every `GET` no more specific endpoint serves; give a member SPA its own prefix (`/app`). Sections:
|
|
139
|
+
`files`, `jobs`, `exports`, `csp` (`connectSrc`, `imgSrc`, `frameAncestors`, `scriptSrc`, `frameSrc`, `styleSrc`,
|
|
140
|
+
`fontSrc`: exact `https://` origins, each with a `purpose`, at most 16 each). A hosted typeface needs both its
|
|
141
|
+
stylesheet origin in `styleSrc` and its font origin in `fontSrc`; font files under the SPA's `dir` need neither. No path under `/__fabrika`.
|
|
142
|
+
- A public form's abuse control, all optional on `c.form`: `rate: { perClient, perForm }` (per minute, shared by
|
|
143
|
+
every instance; 10 and 100 by default; 429 past either), `honeypot: '<field>'` (a `z.optional(z.string())` input
|
|
144
|
+
field the page hides; a filled one gets the same answer and writes nothing) and
|
|
145
|
+
`challenge: { provider: 'turnstile', secret: 'TURNSTILE_SECRET', siteKey: 'TURNSTILE_SITE_KEY' }`. A challenge needs the input field
|
|
146
|
+
`challengeToken: z.string()`, which the page fills from the Turnstile widget rendered with `action` set to the
|
|
147
|
+
form's mount name; the framework verifies it on the server (a failed token 400, an unreachable provider 503, no
|
|
148
|
+
row either way) and adds Turnstile's origin to the CSP. `secret` names an application variable the human sets,
|
|
149
|
+
never the value; without it the application does not boot. `siteKey` names the variable with the widget's public
|
|
150
|
+
site key, which every SPA mount serves its pages (below). Neither field reaches the inbox, so neither is a
|
|
151
|
+
column. On Zerops every visitor still shares one client address (the balancer), so `perClient` acts as a second
|
|
152
|
+
per-form limit: raise it, and use the challenge. `references/public-intake.md` builds a whole public intake on it.
|
|
153
|
+
- `then: '<job>'` on `c.form` names a cron job of the same contract (`jobs`), which each accepted submission kicks
|
|
154
|
+
once its row committed: a run starts within seconds, under the job's own key, and the answer stays the same constant
|
|
155
|
+
and never waits for it. Kicks coalesce on the job's lease (at most one run and one pending, however many visitors
|
|
156
|
+
submit), so the job still drains its inbox in batches under its own budgets. Keep its schedule: it is the recovery
|
|
157
|
+
path for a kicked run that died. A kick carries nothing; the job reads its inbox. `kit.runJob` first waits for the
|
|
158
|
+
kicked runs.
|
|
159
|
+
- `src/app.ts` composes and does nothing else. A dependency built from the application's own variables is
|
|
160
|
+
`defineDeps(build)`, passed as `serve(contract, { database, deps })` and read in a handler with `get()`. Never read
|
|
161
|
+
`process.env`. `build` runs at every boot, the generated tests' too, which pass no variable: read a variable when
|
|
162
|
+
the dependency is used, and fail there (`src/mail.ts` in `references/public-intake.md`). A handler of an endpoint the
|
|
163
|
+
generated tests cover records an outbox row instead, and a job calls the dependency (`references/endpoint.md`).
|
|
164
|
+
|
|
165
|
+
## The deploy surface (Zerops)
|
|
166
|
+
|
|
167
|
+
```ts fabrika.config.ts
|
|
168
|
+
import { compileContract, schemaFromContract } from '@fabrika/app/contract'
|
|
169
|
+
import { defineApp, type ZeropsServiceSpec } from '@fabrika/provider-zerops'
|
|
170
|
+
import { contractProxyTable } from '@fabrika/proxy-core/manifest'
|
|
171
|
+
import { contract } from './fabrika.contract'
|
|
172
|
+
import { tables } from './src/db/tables'
|
|
173
|
+
|
|
174
|
+
const compiled = compileContract(contract, tables)
|
|
175
|
+
|
|
176
|
+
const services = (): ZeropsServiceSpec[] => [
|
|
177
|
+
{
|
|
178
|
+
hostname: 'tasksdb',
|
|
179
|
+
type: 'postgresql:single@18',
|
|
180
|
+
profile: 'oltp-hobby',
|
|
181
|
+
priority: 100,
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
hostname: 'tasksapi',
|
|
185
|
+
type: 'alpine/bun@1.3',
|
|
186
|
+
priority: 10,
|
|
187
|
+
enableSubdomainAccess: false,
|
|
188
|
+
minContainers: 1,
|
|
189
|
+
maxContainers: 2,
|
|
190
|
+
},
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
export default defineApp({
|
|
194
|
+
id: contract.vocabulary.app,
|
|
195
|
+
schema: schemaFromContract(compiled),
|
|
196
|
+
target: {
|
|
197
|
+
platform: 'zerops',
|
|
198
|
+
services,
|
|
199
|
+
contract: compiled,
|
|
200
|
+
proxy: { upstream: 'tasksapi:3000', ...contractProxyTable(compiled) },
|
|
201
|
+
deployService: 'tasksapi',
|
|
202
|
+
zeropsSetup: 'tasksapi',
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```yaml
|
|
208
|
+
# zerops.yaml: write only build.base, build.os and the SPA's buildCommands; the platform generates the rest.
|
|
209
|
+
# Without an SPA, the build holds only base: the platform installs the packages itself.
|
|
210
|
+
zerops:
|
|
211
|
+
- setup: tasksapi
|
|
212
|
+
build:
|
|
213
|
+
base: alpine/bun@1.3
|
|
214
|
+
buildCommands:
|
|
215
|
+
- bun install --frozen-lockfile
|
|
216
|
+
- bun run build:web
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The platform's build runs the tests, writes the entrypoint, runs `fabrika-check` and the type check. Its start script
|
|
220
|
+
runs `fabrika db migrate`, `fabrika db codegen --verify` and `fabrika db test-isolation` before the server: the
|
|
221
|
+
application is not ready until every generated test passes. It writes every `FABRIKA_*` variable; never set one.
|
|
222
|
+
|
|
223
|
+
## The SPA (added when the application has one)
|
|
224
|
+
|
|
225
|
+
The SPA is React, built from the screens and hooks of the framework: `@fabrika/app/react` runs the typed client's
|
|
226
|
+
calls, and `@fabrika/ui` holds the components. Build a screen from the kit; never write a DOM helper, a router or a
|
|
227
|
+
component set of your own. `references/spa.md` shows every component, the forms, the routes and the tests.
|
|
228
|
+
|
|
229
|
+
```html web/index.html
|
|
230
|
+
<!doctype html>
|
|
231
|
+
<html lang="en">
|
|
232
|
+
<head>
|
|
233
|
+
<meta charset="utf-8">
|
|
234
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
235
|
+
<title>Tasks</title>
|
|
236
|
+
<script type="module" src="./start.tsx"></script>
|
|
237
|
+
</head>
|
|
238
|
+
<body>
|
|
239
|
+
<div id="root"></div>
|
|
240
|
+
</body>
|
|
241
|
+
</html>
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
```ts web/api.ts
|
|
245
|
+
import { createClient } from '@fabrika/app/client'
|
|
246
|
+
import { contract } from '../fabrika.contract'
|
|
247
|
+
|
|
248
|
+
export const api = createClient(contract, 'api', { bounceOnAuth: true })
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
```tsx web/mine.tsx
|
|
252
|
+
import { useInfiniteQuery } from '@fabrika/app/react'
|
|
253
|
+
import { type Column, DataTable, PageHeader, StatusPill } from '@fabrika/ui'
|
|
254
|
+
import { api } from './api'
|
|
255
|
+
|
|
256
|
+
type Task = {
|
|
257
|
+
readonly id: string
|
|
258
|
+
readonly title: string
|
|
259
|
+
readonly status: string
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const columns: readonly Column<Task>[] = [
|
|
263
|
+
{ key: 'title', header: 'Task', grow: true, cell: (task) => task.title },
|
|
264
|
+
{
|
|
265
|
+
key: 'status',
|
|
266
|
+
header: 'Status',
|
|
267
|
+
cell: (task) => <StatusPill>{task.status}</StatusPill>,
|
|
268
|
+
},
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
export function MyTasks() {
|
|
272
|
+
const mine = useInfiniteQuery(api.tasks.mine, {})
|
|
273
|
+
return (
|
|
274
|
+
<>
|
|
275
|
+
<PageHeader title="My tasks" lead="Open tasks assigned to you." />
|
|
276
|
+
<DataTable
|
|
277
|
+
caption="My tasks"
|
|
278
|
+
columns={columns}
|
|
279
|
+
rows={mine.items}
|
|
280
|
+
rowKey={(task) => task.id}
|
|
281
|
+
status={mine.status === 'error'
|
|
282
|
+
? 'error'
|
|
283
|
+
: mine.status === 'success'
|
|
284
|
+
? 'ready'
|
|
285
|
+
: 'loading'}
|
|
286
|
+
error={mine.error?.message}
|
|
287
|
+
onRetry={() => void mine.retry()}
|
|
288
|
+
loadMore={{
|
|
289
|
+
hasMore: mine.hasMore,
|
|
290
|
+
loading: mine.loadingMore,
|
|
291
|
+
onLoadMore: () => void mine.loadMore(),
|
|
292
|
+
}}
|
|
293
|
+
/>
|
|
294
|
+
</>
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
```tsx web/start.tsx
|
|
300
|
+
import '@fabrika/ui/styles.css'
|
|
301
|
+
import { FabrikaProvider } from '@fabrika/app/react'
|
|
302
|
+
import { AppShell } from '@fabrika/ui'
|
|
303
|
+
import { StrictMode } from 'react'
|
|
304
|
+
import { createRoot } from 'react-dom/client'
|
|
305
|
+
import { api } from './api'
|
|
306
|
+
import { MyTasks } from './mine'
|
|
307
|
+
|
|
308
|
+
const root = document.getElementById('root')
|
|
309
|
+
if (root !== null) {
|
|
310
|
+
createRoot(root).render(
|
|
311
|
+
<StrictMode>
|
|
312
|
+
<FabrikaProvider clients={[api]}>
|
|
313
|
+
<AppShell
|
|
314
|
+
brand="Tasks"
|
|
315
|
+
nav={[{ label: 'My tasks', href: '/', active: true }]}
|
|
316
|
+
>
|
|
317
|
+
<MyTasks />
|
|
318
|
+
</AppShell>
|
|
319
|
+
</FabrikaProvider>
|
|
320
|
+
</StrictMode>,
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
```ts build-web.ts
|
|
326
|
+
import { shellSlicePlugin } from '@fabrika/check/bun-plugin'
|
|
327
|
+
import { join } from 'node:path'
|
|
328
|
+
|
|
329
|
+
const outdir = join(import.meta.dir, 'dist', 'web')
|
|
330
|
+
const built = await Bun.build({
|
|
331
|
+
entrypoints: [join(import.meta.dir, 'web', 'index.html')],
|
|
332
|
+
outdir,
|
|
333
|
+
target: 'browser',
|
|
334
|
+
define: { 'process.env.NODE_ENV': JSON.stringify('production') },
|
|
335
|
+
plugins: [shellSlicePlugin({ root: import.meta.dir })],
|
|
336
|
+
})
|
|
337
|
+
if (!built.success) {
|
|
338
|
+
for (const log of built.logs) console.error(String(log))
|
|
339
|
+
process.exit(1)
|
|
340
|
+
}
|
|
341
|
+
const index = Bun.file(join(outdir, 'index.html'))
|
|
342
|
+
await Bun.write(
|
|
343
|
+
index,
|
|
344
|
+
(await index.text()).replaceAll(/(src|href)="\.\//g, '$1="/'),
|
|
345
|
+
)
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
- The SPA imports `@fabrika/app/client`, `@fabrika/app/react`, `@fabrika/ui`, React, `fabrika.contract.ts`,
|
|
349
|
+
`src/schemas.ts`, `src/shared/` and its own modules; never a slice, `src/db/` or a Bun or Node module. The plugin loads the
|
|
350
|
+
contract without its handlers. A row type the screen declares (`Task` above) is checked against the answer where
|
|
351
|
+
the rows are passed in.
|
|
352
|
+
- The last step points the page's assets at the SPA's prefix (`/`): the mount answers `index.html` for every path
|
|
353
|
+
under it, so a deep link such as `/tasks/<id>` renders. `define` gives React's production build.
|
|
354
|
+
- Set text through JSX, never `dangerouslySetInnerHTML` or `innerHTML`. Same-origin calls carry the session cookie.
|
|
355
|
+
- **Who is signed in.** Every member SPA serves `<prefix>/__fabrika-me.json` to the person it admitted:
|
|
356
|
+
`{ label, email, actions: { app, scopes } }`. `useCaller()` (`@fabrika/app/react`) answers `{ label, email,
|
|
357
|
+
can(action, scope?) }` from it, and `<Can action="tasks.write">` shows its children only when the token grants the
|
|
358
|
+
action. It comes from the token and asks IAM nothing. Use it to greet the person and to show or hide a control,
|
|
359
|
+
never as the check: every endpoint decides again, so a hidden button is no protection. A public SPA answers it 401,
|
|
360
|
+
because its pages arrive without a token, signed in or not.
|
|
361
|
+
|
|
362
|
+
## Ignored files
|
|
363
|
+
|
|
364
|
+
`.gitignore`: `node_modules/`, `fabrika.manifest.json`, `/fabrika/entry.generated.ts`, `/dist/`.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# references/dev.md — previewing the application with `fabrika app dev`
|
|
2
|
+
|
|
3
|
+
`bunx fabrika app dev` serves the application on `http://127.0.0.1:3000/` through its test kit: the proxy decision,
|
|
4
|
+
the stand-in IAM, a throwaway schema of `FABRIKA_TEST_POSTGRES_URL`, and each SPA from its build (run
|
|
5
|
+
`bun run build:web` first). Use it to look at the screens, never in place of a test.
|
|
6
|
+
|
|
7
|
+
## The seed
|
|
8
|
+
|
|
9
|
+
`dev/seed.ts` runs once, before the first request. It receives the kit, the API the tests use, writes through the
|
|
10
|
+
endpoints, and answers whom and what the picker offers. `--seed=<module>` names another file.
|
|
11
|
+
|
|
12
|
+
```ts dev/seed.ts
|
|
13
|
+
import { createClient } from '@fabrika/app/client'
|
|
14
|
+
import type { DevSeed } from '@fabrika/app/testing'
|
|
15
|
+
import { contract } from '../fabrika.contract'
|
|
16
|
+
|
|
17
|
+
const seed: DevSeed<typeof contract> = async (dev) => {
|
|
18
|
+
const acme = dev.scope('org', 'acme')
|
|
19
|
+
const ada = dev.testCaller([{ role: 'owner', scope: acme }], {
|
|
20
|
+
label: 'Ada Lovelace',
|
|
21
|
+
email: 'ada@acme.example',
|
|
22
|
+
})
|
|
23
|
+
const ben = dev.testCaller([{ role: 'member', scope: acme }], {
|
|
24
|
+
label: 'Ben Okafor',
|
|
25
|
+
email: 'ben@acme.example',
|
|
26
|
+
})
|
|
27
|
+
const api = createClient(contract, 'api', { fetch: dev.browser(ada) })
|
|
28
|
+
await api.projects.create({ orgId: acme.value, name: 'Website relaunch' })
|
|
29
|
+
await api.members.invite({
|
|
30
|
+
orgId: acme.value,
|
|
31
|
+
email: 'cleo@acme.example',
|
|
32
|
+
role: 'member',
|
|
33
|
+
})
|
|
34
|
+
return {
|
|
35
|
+
personas: [
|
|
36
|
+
{ name: 'Ada', description: 'Owns Acme: invites and assigns', caller: ada },
|
|
37
|
+
{ name: 'Ben', description: 'A member who works the tasks', caller: ben },
|
|
38
|
+
],
|
|
39
|
+
scopes: { org: [{ value: acme.value, label: 'Acme' }] },
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default seed
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- A persona is a person the seed made with `dev.testCaller` (a session, never `via: 'key'`), with a unique `name` and
|
|
47
|
+
a one-line `description`. The picker shows its roles, the scope values it holds them in, and `external`.
|
|
48
|
+
- `links` lists pages a link holder opens: `{ title, description, url }`, where `url` is what the application's own
|
|
49
|
+
endpoint answered from `ctx.links.issue` (`/s/#<key>`). Never build a link URL by hand.
|
|
50
|
+
- `scopes` names the values the seed made, by dimension, with the label a person knows them by; the picker offers
|
|
51
|
+
them for a membership role.
|
|
52
|
+
- Check every call the seed makes: an endpoint that refuses answers a status, and the seed goes on without its data.
|
|
53
|
+
A seed that throws stops `app dev` with `seed_failed` and no message, because the message may carry a credential;
|
|
54
|
+
run the same calls in a test to see why.
|
|
55
|
+
- Mail the application sends through its own stand-ins is the seed's business. The invitations IAM would e-mail are
|
|
56
|
+
listed by `app dev` itself.
|
|
57
|
+
|
|
58
|
+
## In the browser
|
|
59
|
+
|
|
60
|
+
- `/__fabrika/dev` is the picker: the personas, another role of the vocabulary (a membership role with a scope value),
|
|
61
|
+
anonymous, the link pages, and the mail IAM sent. It speaks the vocabulary's `locale` and shows its `displayName`.
|
|
62
|
+
- Every SPA page carries a toolbar in the bottom corner: who you are, the personas to switch to (the page reloads as
|
|
63
|
+
that caller), the picker, the link pages, and the mail. An invitation's link accepts it; the browser is the invited
|
|
64
|
+
person from then on.
|
|
65
|
+
- The choice stays in a cookie the application never sees. The data is gone when the command stops.
|
|
66
|
+
- It serves this machine only (`--host` is a loopback address, `--port=0` picks a free port), answers no request
|
|
67
|
+
addressed to another host name, and runs no cron job: test those with `kit.runJob`.
|