@fabrika/app 0.3.2 → 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,305 @@
|
|
|
1
|
+
# references/tests.md — generated tests, seeds and your own tests
|
|
2
|
+
|
|
3
|
+
## The generated tests
|
|
4
|
+
|
|
5
|
+
`bunx fabrika db test-scaffold` writes them from the contract and the table policies, and deletes the ones it no
|
|
6
|
+
longer writes. Commit them; never edit, format or import them (`generated-test-hash`).
|
|
7
|
+
|
|
8
|
+
- `src/db/__tests__/<table>.policy-isolation.test.ts`: the data layer keeps each table's rows in their scope.
|
|
9
|
+
- `src/__tests__/endpoint-access.test.ts`: every `on` and `in` endpoint refuses another scope's object (404 or 403);
|
|
10
|
+
every `on` and `over` endpoint, and every `in` endpoint that declares `sql`, answers nothing of another scope; a
|
|
11
|
+
caller with only an endpoint's write action, plus what IAM requires of it (below), reaches its own object (200). An
|
|
12
|
+
`app` or `creates` endpoint refuses a holder of its action in one scope only (403) and answers a global holder.
|
|
13
|
+
- Their kit stands in for IAM, as `testKit` does (below): it creates a scope, assigns or invites a member and issues a
|
|
14
|
+
link under IAM's rules for the caller's grants. A repeated invitation answers the open one, `resend` restarts its
|
|
15
|
+
7 days, and an organization's address is refused for an external role (`internal_mailbox`). So the caller of an endpoint with the dimension's `members.manage`
|
|
16
|
+
holds it on the object itself, with one role the assigned role's `assignableBy` names and the assigned role's
|
|
17
|
+
actions: the membership roles the input names, or every one of the dimension when it names none. The caller of an
|
|
18
|
+
endpoint that `issues` also holds the actions its link grants.
|
|
19
|
+
|
|
20
|
+
Run them after every change of the contract or the tables. The platform runs them at every start, and the
|
|
21
|
+
application is not ready until each passes. A failing generated test is a bug in the handler, the policy or the
|
|
22
|
+
fixtures, never in the test: do not loosen access to make it pass. A case that throws instead of failing an
|
|
23
|
+
assertion names what the generator could not build: an input the schema refuses, an input the handler refused (400),
|
|
24
|
+
which the fixtures below supply, or a `SeedLimitError` of the seed (below). Do not reshape the contract or the schema
|
|
25
|
+
around one; ask the human.
|
|
26
|
+
|
|
27
|
+
## Fixtures and seeds
|
|
28
|
+
|
|
29
|
+
`src/__tests__/endpoint-access.fixtures.ts` is yours, read statically (plain literals only):
|
|
30
|
+
|
|
31
|
+
```ts src/__tests__/endpoint-access.fixtures.ts
|
|
32
|
+
export const seeds = { 'api.tasks.close': { status: 'doing' } }
|
|
33
|
+
export const members = { 'api.tasks.assign': ['assignee'] }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `export const fixtures = { '<endpoint id>': { … } }` is an input for an endpoint whose schema the scaffold cannot
|
|
37
|
+
sample, such as a `regex` or a webhook's bind:
|
|
38
|
+
`{ 'webhooks.github.bind': { scope: 'acme', account: 'installation:1' } }`. The checker names each missing one,
|
|
39
|
+
and one of an endpoint that does not exist (`endpoint-fixture`). The access form's `from` field is
|
|
40
|
+
replaced by the seeded scope.
|
|
41
|
+
- `members['<endpoint id>']` lists the input fields that name a picked person the handler checks with
|
|
42
|
+
`ctx.members.get` (`assign`'s `assignee`). The generated cases send a real member of the object's org there; without
|
|
43
|
+
it they send a sample, the handler answers 400, and the case fails.
|
|
44
|
+
- `invitations['<endpoint id>']` and `links['<endpoint id>']` list the input fields that name an invitation an endpoint
|
|
45
|
+
cancels (`ctx.members.revoke(ctx.target, { invitationId })`) or a share link it revokes (`ctx.links.revoke(id)`):
|
|
46
|
+
`export const invitations = { 'api.members.cancel': ['invitationId'] }`. The generated cases send one their stand-in
|
|
47
|
+
IAM holds there: an invitation open in the object's value, a link of the endpoint's mount that the case's caller
|
|
48
|
+
issued. Without it they send a sample, IAM answers 404, and the case fails.
|
|
49
|
+
- `seeds['<endpoint id>']` is the state its precondition needs, written into the row of the table that owns the
|
|
50
|
+
endpoint's dimension in both scopes: `close` answers 409 for an open task, so the seed makes it `doing`. A seed
|
|
51
|
+
sets neither a column that places the row nor a text column that holds a marker; a text column a CHECK lists the
|
|
52
|
+
values of, and a foreign key, it may set.
|
|
53
|
+
- `rows['<endpoint id>']` names the input fields that address a row lying in the endpoint's object, with that row's
|
|
54
|
+
table: `export const rows = { 'api.tasks.editComment': { commentId: 'task_comments' } }`. The generated cases seed
|
|
55
|
+
one row of the table in each scope's object and send its key there. The table lies directly in the object: a child
|
|
56
|
+
table of the table that owns it, or a table it contains; the checker refuses a row further down
|
|
57
|
+
(`endpoint-fixture`). Without it they send a sample, the handler
|
|
58
|
+
finds no row (404), and the case fails.
|
|
59
|
+
- The endpoint id is `<mount>.<group>.<endpoint>` (`api.tasks.close`).
|
|
60
|
+
- The seed of the tests fills every text column with a marker and every other required column with a sample of its
|
|
61
|
+
type: an enum's first label, `1`, `false`, today. A column with a `DEFAULT` keeps it (`status` is `open`). A column
|
|
62
|
+
a `CHECK` names gets the first value of an `IN (…)` list, or `null` where it is nullable. A foreign key outside
|
|
63
|
+
the containment chain is `null` where it is nullable, and otherwise references an existing or a seeded row of a
|
|
64
|
+
reference or application table. What the seed cannot satisfy (another `CHECK` on a required text column, a required
|
|
65
|
+
key to a dimension or child table outside the chain) fails the test with a `SeedLimitError` that names the
|
|
66
|
+
constraint: a limit of the generator, not a bug of the handler. Keep the column as the domain needs it, and ask the
|
|
67
|
+
human when the limit blocks the tests.
|
|
68
|
+
- A generated case's input is a sample of the schema: no optional field, `null` for a nullable one, an enum's first
|
|
69
|
+
value, the shortest string its bounds admit (`'x'`), the smallest number, `false`, a fixed value for a format
|
|
70
|
+
(`uuid`, `email`, `iso.date`). When that input meets a precondition (`close` of an `open` task is 409), seed the
|
|
71
|
+
state, or give the whole input in `fixtures`.
|
|
72
|
+
|
|
73
|
+
## Your own tests
|
|
74
|
+
|
|
75
|
+
Test the rules of `logic.ts` as plain functions. Test a handler through `testKit` and the typed client, with each
|
|
76
|
+
allowed call beside its refused twin:
|
|
77
|
+
|
|
78
|
+
```ts src/tasks/__tests__/tasks.test.ts
|
|
79
|
+
import { createClient } from '@fabrika/app/client'
|
|
80
|
+
import { statusOf, testKit } from '@fabrika/app/testing'
|
|
81
|
+
import { testDatabase } from '@fabrika/db/testing'
|
|
82
|
+
import { afterAll, describe, expect, test } from 'bun:test'
|
|
83
|
+
import { contract } from '../../../fabrika.contract'
|
|
84
|
+
import app from '../../app'
|
|
85
|
+
|
|
86
|
+
const postgres = await testDatabase({
|
|
87
|
+
migrations: new URL('../../../db/migrations/', import.meta.url),
|
|
88
|
+
label: 'tasks.test.ts',
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
if (postgres !== null) {
|
|
92
|
+
afterAll(() => postgres.close())
|
|
93
|
+
const kit = testKit(app, { database: postgres.database })
|
|
94
|
+
const acme = kit.scope('org', 'acme')
|
|
95
|
+
const inAcme = (role: 'owner' | 'member') =>
|
|
96
|
+
kit.testCaller([{ role, scope: acme }])
|
|
97
|
+
const owner = inAcme('owner')
|
|
98
|
+
const member = inAcme('member')
|
|
99
|
+
const ownerApi = createClient(contract, 'api', { fetch: kit.browser(owner) })
|
|
100
|
+
const memberApi = createClient(contract, 'api', { fetch: kit.browser(member) })
|
|
101
|
+
|
|
102
|
+
describe('tasks', () => {
|
|
103
|
+
test('an owner assigns a task and a member may not; the assignee sees it under mine', async () => {
|
|
104
|
+
const project = await ownerApi.projects.create({
|
|
105
|
+
orgId: 'acme',
|
|
106
|
+
name: 'Launch',
|
|
107
|
+
})
|
|
108
|
+
const task = await memberApi.tasks.create({
|
|
109
|
+
projectId: project.id,
|
|
110
|
+
title: 'Write the brief',
|
|
111
|
+
})
|
|
112
|
+
expect(
|
|
113
|
+
await statusOf(
|
|
114
|
+
memberApi.tasks.assign({
|
|
115
|
+
id: task.id,
|
|
116
|
+
expectVersion: 1,
|
|
117
|
+
assignee: member.id,
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
120
|
+
).toBe(403)
|
|
121
|
+
expect(
|
|
122
|
+
await ownerApi.tasks.assign({
|
|
123
|
+
id: task.id,
|
|
124
|
+
expectVersion: 1,
|
|
125
|
+
assignee: member.id,
|
|
126
|
+
}),
|
|
127
|
+
).toEqual({ id: task.id, version: 2 })
|
|
128
|
+
expect((await memberApi.tasks.mine({})).items.map((item) => item.id))
|
|
129
|
+
.toEqual([task.id])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('only a task in progress closes', async () => {
|
|
133
|
+
const project = await ownerApi.projects.create({
|
|
134
|
+
orgId: 'acme',
|
|
135
|
+
name: 'Ops',
|
|
136
|
+
})
|
|
137
|
+
const task = await memberApi.tasks.create({
|
|
138
|
+
projectId: project.id,
|
|
139
|
+
title: 'Rotate keys',
|
|
140
|
+
dueOn: '2026-10-20',
|
|
141
|
+
})
|
|
142
|
+
expect(
|
|
143
|
+
await statusOf(memberApi.tasks.close({ id: task.id, expectVersion: 1 })),
|
|
144
|
+
).toBe(409)
|
|
145
|
+
await memberApi.tasks.start({ id: task.id, expectVersion: 1 })
|
|
146
|
+
expect(await memberApi.tasks.close({ id: task.id, expectVersion: 2 }))
|
|
147
|
+
.toEqual({ id: task.id, version: 3 })
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test("the member directory lists the org's members", async () => {
|
|
151
|
+
const listed = await memberApi.members.list({ orgId: 'acme' })
|
|
152
|
+
expect(listed.items.map((item) => item.principalId).sort()).toEqual(
|
|
153
|
+
[owner.id, member.id].sort(),
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('a member lists its orgs; a global grant lists none and says all', async () => {
|
|
158
|
+
expect(await memberApi.members.orgs()).toEqual({
|
|
159
|
+
all: false,
|
|
160
|
+
orgs: ['acme'],
|
|
161
|
+
})
|
|
162
|
+
const auditor = createClient(contract, 'api', {
|
|
163
|
+
fetch: kit.browser(kit.testCaller([{ action: 'tasks.read' }])),
|
|
164
|
+
})
|
|
165
|
+
expect(await auditor.members.orgs()).toEqual({ all: true, orgs: [] })
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('a caller of another org reaches nothing', async () => {
|
|
169
|
+
const project = await ownerApi.projects.create({
|
|
170
|
+
orgId: 'acme',
|
|
171
|
+
name: 'Secret',
|
|
172
|
+
})
|
|
173
|
+
const outsider = createClient(contract, 'api', {
|
|
174
|
+
fetch: kit.browser(
|
|
175
|
+
kit.testCaller([{
|
|
176
|
+
role: 'owner',
|
|
177
|
+
scope: { type: 'org', value: 'other' },
|
|
178
|
+
}]),
|
|
179
|
+
),
|
|
180
|
+
})
|
|
181
|
+
expect(
|
|
182
|
+
await statusOf(
|
|
183
|
+
outsider.tasks.create({ projectId: project.id, title: 'Not mine' }),
|
|
184
|
+
),
|
|
185
|
+
).toBe(403)
|
|
186
|
+
expect((await outsider.tasks.mine({})).items).toEqual([])
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- `testDatabase` migrates a throwaway schema; without `FABRIKA_TEST_POSTGRES_URL` it answers `null`, prints
|
|
193
|
+
`<label> skipped: set FABRIKA_TEST_POSTGRES_URL to run the Postgres-backed tests`, and the file registers no test.
|
|
194
|
+
A skip proves nothing. To confirm a file ran, run it alone (`bun test --isolate src/tasks`) and check that the pass
|
|
195
|
+
count is its number of tests, or set `FABRIKA_TEST_POSTGRES_REQUIRED=1`: a missing URL then fails the file.
|
|
196
|
+
- `statusOf(call)` answers 200 when the call resolves and the status of the application's refusal otherwise; any other
|
|
197
|
+
failure (no answer, a bug in the test) is rethrown. Import it beside `testKit`; do not write your own.
|
|
198
|
+
- `kit.testCaller(grants, { kind })` makes a caller: a grant is a role or an action, in one scope or global. A
|
|
199
|
+
membership role is granted in one value of its scope; an action without `scope` is a global grant, for which
|
|
200
|
+
`ctx.within.values` is `'all'` (`kit.testCaller([{ action: 'tasks.read' }])`). `kind: 'machine'` presents a `px_`
|
|
201
|
+
key (`createClient(contract, 'api', { fetch: kit.fetch, key: caller.key })`).
|
|
202
|
+
- Build a scope you reuse with `kit.scope('org', 'acme')` (`acme` above). An object literal stored in a variable
|
|
203
|
+
widens its `type` to `string`, which tsc refuses as a grant's `scope`; a dimension the contract lacks is a type error.
|
|
204
|
+
- A caller's `id` is `<kind>-<n>` and its `label` `<kind>-<n>@<app>.test`, `n` counting the kit's callers; pass
|
|
205
|
+
`{ id, label }` to fix either (`kit.testCaller(grants, { label: 'Ann' })`). The label is a `string`, only a link
|
|
206
|
+
holder's (`kind: 'link'`) is `null`; it is `ctx.principal.label`, and the directory's label where it shows labels.
|
|
207
|
+
Assert a stored label against `caller.label` or the directory's answer (`members.list` above), never against a
|
|
208
|
+
literal of the default form.
|
|
209
|
+
- A human's `email` is `ctx.principal.email`: `<kind>-<n>@<app>.test` unless you pass `{ email }`, which the kit
|
|
210
|
+
lower-cases as IAM does (`kit.testCaller(grants, { email: 'Ann@Example.com' })` mails `ann@example.com`). A
|
|
211
|
+
`machine`, `app` or `link` caller's `email` is `null`. The SPA's `<prefix>/__fabrika-me.json` answers the caller's
|
|
212
|
+
label, email and actions in the kit as in production (`kit.browser(caller)('/__fabrika-me.json')`).
|
|
213
|
+
- `kit.browser(caller)` and `kit.fetch` serve one origin, `https://<app>.test` (`kit.origin`). Call a route by its
|
|
214
|
+
path (`kit.browser(owner)('/api/tasks.get', { method: 'POST', … })`), never with another host.
|
|
215
|
+
- An SPA mount is served from its built `dir` (`dist/web`) when the build exists, as the deploy serves it:
|
|
216
|
+
`kit.browser(owner)('/')` answers the built `index.html`, a file by its path, and every other path of the mount the
|
|
217
|
+
`index.html` again. Without a build it is an empty page. The directory is relative to the working directory, the
|
|
218
|
+
application root under `bun test`; pass `testKit(app, { root })` from elsewhere.
|
|
219
|
+
- The kit stands in for IAM's membership operations. A member directory (`ctx.members.list()` and `get(id)`) answers
|
|
220
|
+
from the kit's own callers: everyone `testCaller` gave a membership role in that scope, labels only where the
|
|
221
|
+
dimension declares `directory: 'labels'` (or to a `members.manage` holder). `kit.testCaller(grants, { lastSignInAt })`
|
|
222
|
+
is a person who signed in then; a `members.manage` holder's `list(ref)` answers it, and `null` for everyone else. The
|
|
223
|
+
application's own directory (`ctx.members.list()` of an `app: true` endpoint) lists every caller holding a role of the
|
|
224
|
+
contract, by id. To list someone, make them a caller:
|
|
225
|
+
`kit.testCaller([{ role: 'member', scope: { type: 'org', value: 'acme' } }])`. A `creates` endpoint, member
|
|
226
|
+
management and share links answer under IAM's rules for the caller's grants, and a role they give or remove is live
|
|
227
|
+
from the caller's next request, as in IAM: the creator of a value holds its creator role, a removed member is
|
|
228
|
+
refused and no longer listed.
|
|
229
|
+
- `kit.acceptInvitation(invitationId)` is the invited person accepting an invitation your handler opened
|
|
230
|
+
(`ctx.members.assign(ctx.target, { email }, role)`): it answers a new caller labelled by the address, holding the
|
|
231
|
+
role in that scope (an `external` person for an external role). Pass a caller to make an existing person the
|
|
232
|
+
member: `kit.acceptInvitation(invitationId, member)`. It throws as IAM refuses: an invitation that is not open, an
|
|
233
|
+
external person and a role not declared external (`not_eligible`), an inviter that has since lost the authority to
|
|
234
|
+
give the role (`not_allowed`).
|
|
235
|
+
|
|
236
|
+
```ts src/members/__tests__/members.test.ts
|
|
237
|
+
import { createClient } from '@fabrika/app/client'
|
|
238
|
+
import { statusOf, testKit } from '@fabrika/app/testing'
|
|
239
|
+
import { testDatabase } from '@fabrika/db/testing'
|
|
240
|
+
import { afterAll, expect, test } from 'bun:test'
|
|
241
|
+
import { contract } from '../../../fabrika.contract'
|
|
242
|
+
import app from '../../app'
|
|
243
|
+
|
|
244
|
+
const postgres = await testDatabase({
|
|
245
|
+
migrations: new URL('../../../db/migrations/', import.meta.url),
|
|
246
|
+
label: 'members.test.ts',
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
if (postgres !== null) {
|
|
250
|
+
afterAll(() => postgres.close())
|
|
251
|
+
const kit = testKit(app, { database: postgres.database })
|
|
252
|
+
const owner = kit.testCaller([{
|
|
253
|
+
role: 'owner',
|
|
254
|
+
scope: kit.scope('org', 'acme'),
|
|
255
|
+
}])
|
|
256
|
+
const ownerApi = createClient(contract, 'api', { fetch: kit.browser(owner) })
|
|
257
|
+
|
|
258
|
+
test('an invited person accepts and is listed; once removed, it is refused', async () => {
|
|
259
|
+
const { invitationId } = await ownerApi.members.invite({
|
|
260
|
+
orgId: 'acme',
|
|
261
|
+
email: 'ann@example.test',
|
|
262
|
+
role: 'member',
|
|
263
|
+
})
|
|
264
|
+
const ann = kit.acceptInvitation(invitationId)
|
|
265
|
+
const annApi = createClient(contract, 'api', { fetch: kit.browser(ann) })
|
|
266
|
+
expect(
|
|
267
|
+
(await annApi.members.list({ orgId: 'acme' })).items.map((item) =>
|
|
268
|
+
item.principalId
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
.toContain(ann.id)
|
|
272
|
+
await ownerApi.members.remove({ orgId: 'acme', principalId: ann.id })
|
|
273
|
+
expect(await statusOf(annApi.members.list({ orgId: 'acme' }))).toBe(403)
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
- `testKit(app, { membership })` replaces the stand-in with your own `IamMembershipRpc`; that kit accepts no
|
|
279
|
+
invitation. `@fabrika/app/testing` exports the types of the options: `IamMembershipRpc` (`membership`),
|
|
280
|
+
`ChallengeFetch` (`challengeFetch`) and `FileScanner` (`scanner`). Import them from there, beside `testKit`.
|
|
281
|
+
- `kit.testCaller(grants, { disabled: true })` is a person an operator disabled: the directory lists and finds them
|
|
282
|
+
with `disabled: true`, so an assignment of them answers your handler's 400, and their own requests are refused (401 for
|
|
283
|
+
a session, 403 for a key).
|
|
284
|
+
- `kit.runJob('<job>')` runs a cron job once and answers `{ status: 'succeeded', attempts }`.
|
|
285
|
+
- An application with files passes `files: new MemoryFileObjects()` (`@fabrika/platform/testing`) to `testKit`;
|
|
286
|
+
without it the application does not boot. With `scan: true`, an upload stays `quarantined` until
|
|
287
|
+
`kit.runJob(FILE_SCAN_JOB)` (`@fabrika/app/implement`) runs with a `scanner` passed to `testKit`: a `FileScanner`
|
|
288
|
+
stand-in, or the real ClamAV one, `clamscanScanner()` of `@fabrika/app/bun`.
|
|
289
|
+
- A form's challenge token is single use: a second submission with the same token answers 400 `challenge_failed`,
|
|
290
|
+
also when the test's `challengeFetch` answers success. Send a fresh token with each submission
|
|
291
|
+
(`crypto.randomUUID()`).
|
|
292
|
+
- `testKit` takes no `deps`, and a test may not start a server (`Bun.serve` is `forbidden-global`). Stand in for an
|
|
293
|
+
outbound dependency through its variables: `env` points it at a test URL, and `spyOn(globalThis, 'fetch')` answers
|
|
294
|
+
that URL only and passes every other request on (`src/__tests__/mail-stand-in.ts` in `references/public-intake.md`).
|
|
295
|
+
- `defineDeps(build)` runs at every boot, the generated tests' too, which set no application variable: read a variable
|
|
296
|
+
where it is used, not in `build` (`references/public-intake.md`). A handler that calls an outbound dependency fails its
|
|
297
|
+
generated cases with 500, so an endpoint they cover writes an outbox row a job sends (`references/endpoint.md`).
|
|
298
|
+
- Run with `bun test --isolate`: the generated test boots the same application, and its data layer binds once per
|
|
299
|
+
process. Import the test kits from test files only (`test-only-import`).
|
|
300
|
+
- No `.skip`, `.only` or test without an assertion, and no `mock.module` of `bun:test` or `@fabrika/*`.
|
|
301
|
+
|
|
302
|
+
## Clicking through the screens
|
|
303
|
+
|
|
304
|
+
`bunx fabrika app dev` serves the application through the same kit, seeded by `dev/seed.ts`, with a caller picker at
|
|
305
|
+
`/__fabrika/dev` and a toolbar on every SPA page: `references/dev.md`.
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# references/webhooks-links-connectors.md — webhooks, share links and connectors
|
|
2
|
+
|
|
3
|
+
Each of these lets someone in who is not a signed-in member of the scope. Ask the human before you add one, and say
|
|
4
|
+
what it lets that caller do.
|
|
5
|
+
|
|
6
|
+
## Webhooks (GitHub)
|
|
7
|
+
|
|
8
|
+
```ts src/github/endpoints.ts
|
|
9
|
+
import { z } from 'zod/v4'
|
|
10
|
+
import { app } from '../app-kit'
|
|
11
|
+
|
|
12
|
+
const RepositoryEvent = z.strictObject({
|
|
13
|
+
action: z.string(),
|
|
14
|
+
repository: z.strictObject({ full_name: z.string().max(200) }),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/** A repository created in the bound GitHub account becomes a project of the org the binding is for. */
|
|
18
|
+
export const github = app.webhook('/hooks/github', {
|
|
19
|
+
scheme: 'github-sha256',
|
|
20
|
+
boundTo: 'org',
|
|
21
|
+
actions: ['tasks.write'],
|
|
22
|
+
input: RepositoryEvent,
|
|
23
|
+
})
|
|
24
|
+
.handle(async ({ input, ctx }) => {
|
|
25
|
+
if (input.action !== 'created') return
|
|
26
|
+
await ctx.db.in(ctx.refs['tasks.write']).projects.insert({
|
|
27
|
+
name: input.repository.full_name,
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- Mount it by name in the contract: `github,` beside `api`. It answers `POST /hooks/github/:bindingId`.
|
|
33
|
+
- `boundTo` is a dimension with `members`. A holder of its `members.manage` binds an org to a GitHub account with the
|
|
34
|
+
generated `POST /api/webhooks.github.bind` (`{ scope, account: 'installation:<id>' | 'repository:<id>' }`), which
|
|
35
|
+
answers the secret and the URL once. The generated tests need a fixture for it (`references/tests.md`).
|
|
36
|
+
- The framework checks the signature, the account and the input (undeclared fields are dropped).
|
|
37
|
+
`ctx.refs['<action>']` is a `Ref` of the bound org for each declared action.
|
|
38
|
+
- It records the delivery key (a digest of the binding and the body) before the handler runs, and answers a body it
|
|
39
|
+
already ran in the last 30 days without running it again. A handler that throws commits nothing, and the key is
|
|
40
|
+
forgotten, so GitHub's redelivery runs it again from nothing. A process that dies after the record and before the
|
|
41
|
+
commit keeps the key and loses the delivery. So make every effect idempotent, and key an external effect after the
|
|
42
|
+
data work on `ctx.delivery.key`; never rely on exactly one run.
|
|
43
|
+
|
|
44
|
+
## Share links
|
|
45
|
+
|
|
46
|
+
```ts src/sharing/endpoints.ts
|
|
47
|
+
import { z } from 'zod/v4'
|
|
48
|
+
import { app } from '../app-kit'
|
|
49
|
+
import { TaskStatus } from '../schemas'
|
|
50
|
+
|
|
51
|
+
const ShareTask = z.strictObject({ id: z.uuid(), days: z.int().min(1).max(30) })
|
|
52
|
+
const Shared = z.strictObject({ url: z.string() })
|
|
53
|
+
const SharedTask = z.strictObject({ title: z.string(), status: TaskStatus })
|
|
54
|
+
|
|
55
|
+
export const sharing = app.group({ callers: ['human'] }, (r) => ({
|
|
56
|
+
share: r.mutation({
|
|
57
|
+
input: ShareTask,
|
|
58
|
+
output: Shared,
|
|
59
|
+
access: { action: 'tasks.write', on: 'task', from: 'id' },
|
|
60
|
+
issues: 'shared',
|
|
61
|
+
})
|
|
62
|
+
.handle(async ({ input, ctx }) => ({
|
|
63
|
+
url:
|
|
64
|
+
(await ctx.links.issue(ctx.target, { mount: 'shared', days: input.days }))
|
|
65
|
+
.url,
|
|
66
|
+
})),
|
|
67
|
+
}))
|
|
68
|
+
|
|
69
|
+
export const shared = app.link('/s', {
|
|
70
|
+
spa: 'dist/share',
|
|
71
|
+
dimension: 'task',
|
|
72
|
+
grants: ['tasks.read'],
|
|
73
|
+
issuedBy: 'tasks.write',
|
|
74
|
+
maxDays: 30,
|
|
75
|
+
procedures: (l) => ({
|
|
76
|
+
task: l.query({ output: SharedTask, access: { action: 'tasks.read' } })
|
|
77
|
+
.handle(async ({ ctx }) => {
|
|
78
|
+
const task = await ctx.db.target.get()
|
|
79
|
+
return { title: task.title, status: task.status }
|
|
80
|
+
}),
|
|
81
|
+
}),
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- Mount both: `api: c.rpc('/api', { …, sharing })` and `shared,`. The link's procedures are `POST /s/api/<name>` and
|
|
86
|
+
its SPA is served under `/s/`.
|
|
87
|
+
- The issuing endpoint declares `issues: '<mount>'` and its action is the mount's `issuedBy`. IAM binds the key to the
|
|
88
|
+
task's whole chain; the answer is `<prefix>/#<key>`, and a moved task's key reaches nothing.
|
|
89
|
+
- The share SPA reads the key with `shareKey()` of `@fabrika/app/client` and calls
|
|
90
|
+
`createClient(contract, 'shared', { key })`. Never read `location.hash`.
|
|
91
|
+
- Issue the link after the data work: IAM does not roll back with the transaction.
|
|
92
|
+
- `ctx.links.issue` answers `{ id, url, expiresAt }`. A holder of that link calls with `ctx.principal.kind === 'link'`
|
|
93
|
+
and `ctx.principal.credentialId` equal to that `id`, so a row that stores the `id` finds what the link was issued
|
|
94
|
+
for. Keep the `id` (never the key) to end the link later with `ctx.links.revoke(id)`, which the same `issues` declaration gives; its issuer or a holder of `members.manage` on its
|
|
95
|
+
chain may. A revoked link still works until the proxy's token for it expires, at most 5 minutes.
|
|
96
|
+
- A link procedure acts on the link's own object (`ctx.db.target`), with no `from` and no id in its input. A link
|
|
97
|
+
that should work once records its use in the row: a `verify` sets `verified_at` once under `getForUpdate`, and a
|
|
98
|
+
second call answers the same. IAM counts no uses.
|
|
99
|
+
- A cron job may issue links too (`issues` on `app.cron`, `references/jobs.md`): a visitor without an account gets a
|
|
100
|
+
link by e-mail from a job that drains a form's inbox. `references/public-intake.md` is that recipe end to end, with
|
|
101
|
+
the files a link holder uploads (`link: true`, or `link: ['<mount>']` for some mounts only, `references/files.md`).
|
|
102
|
+
|
|
103
|
+
## Connectors: another application's data
|
|
104
|
+
|
|
105
|
+
A consumer imports an owner's export. The snapshot comes from Control, so this step needs an operator's credential,
|
|
106
|
+
not the application's: `FABRIKA_CONTROL_URL` (or `--url=<control origin>`) and `FABRIKA_CONTROL_KEY`, a machine key
|
|
107
|
+
that holds `deploy.read` on the consumer, both in the shell that runs the command only. Ask the human for them; they
|
|
108
|
+
are CLI credentials, never application variables, and never committed.
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
bunx fabrika connect add catalog/products@1 --consumer=tasks --env=prod # writes fabrika/connect/catalog.products.v1.ts
|
|
112
|
+
bunx fabrika connect pull catalog/products@1 --consumer=tasks --env=prod # refreshes it from what the owner approved now
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The kit takes the snapshot, and an endpoint that calls it declares `connect`:
|
|
116
|
+
|
|
117
|
+
```ts src/app-kit.ts
|
|
118
|
+
import { defineApp } from '@fabrika/app/contract'
|
|
119
|
+
import { catalogProductsV1 } from '../fabrika/connect/catalog.products.v1'
|
|
120
|
+
import { tables } from './db/tables'
|
|
121
|
+
import { vocabulary } from './vocabulary'
|
|
122
|
+
|
|
123
|
+
export const app = defineApp(vocabulary, tables, {
|
|
124
|
+
imports: { catalog: { snapshot: catalogProductsV1, procedures: ['get'] } },
|
|
125
|
+
})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```ts src/products/endpoints.ts
|
|
129
|
+
import { z } from 'zod/v4'
|
|
130
|
+
import { app } from '../app-kit'
|
|
131
|
+
|
|
132
|
+
const BySku = z.strictObject({
|
|
133
|
+
orgId: z.string().min(1).max(100),
|
|
134
|
+
sku: z.string().min(1).max(64),
|
|
135
|
+
})
|
|
136
|
+
const ProductName = z.strictObject({
|
|
137
|
+
name: z.nullable(z.string()),
|
|
138
|
+
status: z.enum(['found', 'missing', 'unavailable']),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
export const products = app.group({ callers: ['human'] }, (r) => ({
|
|
142
|
+
name: r.query({
|
|
143
|
+
input: BySku,
|
|
144
|
+
output: ProductName,
|
|
145
|
+
access: { action: 'tasks.read', in: 'org', from: 'orgId' },
|
|
146
|
+
connect: ['catalog'],
|
|
147
|
+
})
|
|
148
|
+
.handle(async ({ input, ctx }) => {
|
|
149
|
+
const product = await ctx.connect.catalog.get({ sku: input.sku })
|
|
150
|
+
if (product.outcome !== 'ok') return { name: null, status: 'unavailable' }
|
|
151
|
+
return product.value === null
|
|
152
|
+
? { name: null, status: 'missing' }
|
|
153
|
+
: { name: product.value.name, status: 'found' }
|
|
154
|
+
}),
|
|
155
|
+
}))
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- A call never throws for the owner's answer and never becomes an HTTP error by itself: it resolves to an outcome,
|
|
159
|
+
`ok`, `denied`, `invalid_input`, `unavailable`, `timeout` or `contract_violation`. Decide what each one answers, as
|
|
160
|
+
the example does: a missing product and an unreachable owner are different answers.
|
|
161
|
+
- Exports are read-only and carry no per-user data. When the export you need does not exist, ask the owner
|
|
162
|
+
application's maintainer. The platform writes `FABRIKA_CONNECT_*`; never read or set them.
|
|
163
|
+
|
|
164
|
+
The owner side is an export, mounted under `exports` in the contract. Its procedures run read-only for the consumer
|
|
165
|
+
applications it names:
|
|
166
|
+
|
|
167
|
+
```ts src/billing/endpoints.ts
|
|
168
|
+
import { z } from 'zod/v4'
|
|
169
|
+
import { app } from '../app-kit'
|
|
170
|
+
|
|
171
|
+
const ProjectCount = z.strictObject({ projects: z.int() })
|
|
172
|
+
|
|
173
|
+
export const projectCounts = app.export({
|
|
174
|
+
version: 1,
|
|
175
|
+
consumers: ['billing'],
|
|
176
|
+
audience: 'any consumer user',
|
|
177
|
+
procedures: (e) => ({
|
|
178
|
+
count: e.query({
|
|
179
|
+
output: ProjectCount,
|
|
180
|
+
access: { action: 'tasks.read', over: 'org' },
|
|
181
|
+
})
|
|
182
|
+
.handle(async ({ ctx }) => ({
|
|
183
|
+
projects: await ctx.db.within.projects.count(),
|
|
184
|
+
})),
|
|
185
|
+
}),
|
|
186
|
+
})
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Change a published export only by adding to it (a new procedure, a new optional input field) or by a new version
|
|
190
|
+
served beside the old one.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @fabrika/app/agent-skill — the one source of the rules every agent that writes an application reads: the builder
|
|
2
|
+
// installs `skill/` as a skill, Control's MCP server answers it from `get_app_rules`, and the template's AGENTS.md is
|
|
3
|
+
// `renderAgentsFile`. Reads files of a package, so only Bun or Node code imports it.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
6
|
+
import { dirname, join } from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
|
|
9
|
+
export const APP_AGENT_SKILL_NAME = 'fabrika-app'
|
|
10
|
+
|
|
11
|
+
const SKILL_DIR = fileURLToPath(new URL('../skill', import.meta.url))
|
|
12
|
+
const SKILL_FILE = 'SKILL.md'
|
|
13
|
+
const REFERENCES_DIR = 'references'
|
|
14
|
+
const FRONTMATTER = /^---\n([\s\S]*?)\n---\n+/
|
|
15
|
+
const VERSION_LINE = /^version: (\S+)$/m
|
|
16
|
+
|
|
17
|
+
export interface AppAgentSkillReference {
|
|
18
|
+
/** The file's path in the skill directory, `references/<topic>.md`: the path `SKILL.md` names it by. */
|
|
19
|
+
readonly path: string
|
|
20
|
+
/** The topic `get_app_rules` answers it under: its file name without `.md`. */
|
|
21
|
+
readonly topic: string
|
|
22
|
+
readonly text: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AppAgentSkill {
|
|
26
|
+
/** The `version` of the frontmatter: the version of the `@fabrika/app` that ships it. */
|
|
27
|
+
readonly version: string
|
|
28
|
+
/** The whole `SKILL.md`, frontmatter included: what a skill directory holds. */
|
|
29
|
+
readonly text: string
|
|
30
|
+
/** `SKILL.md` without the frontmatter. */
|
|
31
|
+
readonly body: string
|
|
32
|
+
/** The reference files, by path. */
|
|
33
|
+
readonly references: readonly AppAgentSkillReference[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The skill of this package. */
|
|
37
|
+
export function readAppAgentSkill(): AppAgentSkill {
|
|
38
|
+
return readAppAgentSkillAt(SKILL_DIR)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The skill in `skillDir`, the `skill/` directory of an installed `@fabrika/app`. */
|
|
42
|
+
export function readAppAgentSkillAt(skillDir: string): AppAgentSkill {
|
|
43
|
+
const text = readFileSync(join(skillDir, SKILL_FILE), 'utf-8')
|
|
44
|
+
const frontmatter = FRONTMATTER.exec(text)
|
|
45
|
+
if (frontmatter === null) throw new Error('@fabrika/app: skill/SKILL.md has no frontmatter')
|
|
46
|
+
const version = VERSION_LINE.exec(frontmatter[1] ?? '')?.[1]
|
|
47
|
+
if (version === undefined) throw new Error('@fabrika/app: skill/SKILL.md has no version in its frontmatter')
|
|
48
|
+
const referencesDir = join(skillDir, REFERENCES_DIR)
|
|
49
|
+
const names = existsSync(referencesDir) ? readdirSync(referencesDir).filter((name) => name.endsWith('.md')).sort() : []
|
|
50
|
+
const references = names.map((name) => ({
|
|
51
|
+
path: `${REFERENCES_DIR}/${name}`,
|
|
52
|
+
topic: name.slice(0, -'.md'.length),
|
|
53
|
+
text: readFileSync(join(referencesDir, name), 'utf-8'),
|
|
54
|
+
}))
|
|
55
|
+
return { version, text, body: text.slice(frontmatter[0].length), references }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The skill of the `@fabrika/app` an application installed: the nearest `node_modules/@fabrika/app` from `appRoot`
|
|
60
|
+
* up, as Bun resolves the package. `null` when the application has not installed it.
|
|
61
|
+
*/
|
|
62
|
+
export function installedAppAgentSkill(appRoot: string): AppAgentSkill | null {
|
|
63
|
+
let directory = appRoot
|
|
64
|
+
for (;;) {
|
|
65
|
+
const skillDir = join(directory, 'node_modules', '@fabrika', 'app', 'skill')
|
|
66
|
+
if (existsSync(join(skillDir, SKILL_FILE))) return readAppAgentSkillAt(skillDir)
|
|
67
|
+
const parent = dirname(directory)
|
|
68
|
+
if (parent === directory) return null
|
|
69
|
+
directory = parent
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The `AGENTS.md` of an application repository: the skill's body, then each reference file. */
|
|
74
|
+
export function renderAgentsFile(skill: AppAgentSkill): string {
|
|
75
|
+
const marker = `<!-- Generated from skill/ of @fabrika/app ${skill.version}. Change those files, not this one. -->\n\n`
|
|
76
|
+
return marker + [skill.body, ...skill.references.map((reference) => reference.text)].join('\n---\n\n')
|
|
77
|
+
}
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @fabrika/app/build — the generated entrypoint of a contract application, which `fabrika app build` writes. It is
|
|
2
|
+
// the only module that reads `process.env` and the only caller of `boot`, through the runtime adapter, and it imports
|
|
3
|
+
// the application only after the adapter took the framework's variables out of the process.
|
|
4
|
+
|
|
5
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
6
|
+
import { dirname, join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
/** An application root with this file is a contract application. The paths equal `@fabrika/check`'s layout. */
|
|
9
|
+
export const CONTRACT_FILE = 'fabrika.contract.ts'
|
|
10
|
+
|
|
11
|
+
/** Relative to the application root. */
|
|
12
|
+
export const GENERATED_ENTRY_FILE = 'fabrika/entry.generated.ts'
|
|
13
|
+
|
|
14
|
+
/** The module whose default export is `serve(contract, …)`, relative to the application root. */
|
|
15
|
+
export const IMPLEMENTATION_FILE = 'src/app.ts'
|
|
16
|
+
|
|
17
|
+
export type EntryRuntime = 'bun' | 'cloudflare'
|
|
18
|
+
|
|
19
|
+
const HEADER = '// @generated by `fabrika app build` from src/app.ts. Do not edit: every build rewrites it.'
|
|
20
|
+
|
|
21
|
+
/** The entrypoint source for one runtime. */
|
|
22
|
+
export function generatedEntry(runtime: EntryRuntime): string {
|
|
23
|
+
switch (runtime) {
|
|
24
|
+
case 'bun':
|
|
25
|
+
return [
|
|
26
|
+
HEADER,
|
|
27
|
+
"import { markProductionProcess, serveContract } from '@fabrika/app/bun'",
|
|
28
|
+
'',
|
|
29
|
+
'// The test kits refuse to load from here on; the application is imported only after the framework took its variables.',
|
|
30
|
+
'markProductionProcess()',
|
|
31
|
+
"await serveContract(() => import('../src/app'), process.env)",
|
|
32
|
+
'',
|
|
33
|
+
].join('\n')
|
|
34
|
+
case 'cloudflare':
|
|
35
|
+
return [
|
|
36
|
+
HEADER,
|
|
37
|
+
"import { createContractWorker, markProductionProcess } from '@fabrika/app/cloudflare'",
|
|
38
|
+
'',
|
|
39
|
+
'// The test kits refuse to load from here on; the application is imported only after the framework took its variables.',
|
|
40
|
+
'markProductionProcess()',
|
|
41
|
+
"export default createContractWorker(() => import('../src/app'))",
|
|
42
|
+
'',
|
|
43
|
+
].join('\n')
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Writes the entrypoint under `root` and returns its path. */
|
|
48
|
+
export async function writeGeneratedEntry(root: string, runtime: EntryRuntime): Promise<string> {
|
|
49
|
+
const path = join(root, GENERATED_ENTRY_FILE)
|
|
50
|
+
await mkdir(dirname(path), { recursive: true })
|
|
51
|
+
await writeFile(path, generatedEntry(runtime))
|
|
52
|
+
return path
|
|
53
|
+
}
|