@fabrika/app 0.8.2 → 0.9.0-cloudflare.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +40 -24
- package/skill/SKILL.md +29 -3
- package/skill/references/app-setup.md +29 -11
- package/skill/references/dev.md +2 -1
- package/skill/references/endpoint.md +12 -3
- package/skill/references/errors.md +39 -35
- package/skill/references/files.md +4 -2
- package/skill/references/jobs.md +9 -0
- package/skill/references/table-and-migration.md +19 -2
- package/skill/references/tests.md +33 -1
- package/skill/references/webhooks-links-connectors.md +1 -1
- package/src/build-recipe.ts +198 -0
- package/src/build.ts +60 -4
- package/src/bun.ts +33 -8
- package/src/cloudflare/application.ts +352 -0
- package/src/cloudflare/bindings.ts +159 -0
- package/src/cloudflare/names.ts +16 -0
- package/src/cloudflare/ops.ts +246 -0
- package/src/cloudflare/r2-files.ts +172 -0
- package/src/cloudflare.ts +99 -146
- package/src/contract/app-id.ts +5 -0
- package/src/contract/artifact-schema.ts +8 -1
- package/src/contract/build.ts +68 -11
- package/src/contract/compile.ts +1 -0
- package/src/contract/context.ts +9 -0
- package/src/contract/declaration.ts +2 -0
- package/src/contract/define.ts +28 -2
- package/src/contract/group.ts +10 -1
- package/src/contract/kit.ts +6 -2
- package/src/contract/platform.ts +3 -2
- package/src/contract/vocabulary.ts +26 -8
- package/src/dev/changes.ts +35 -0
- package/src/dev/contract-digest.ts +17 -0
- package/src/dev/index.ts +11 -0
- package/src/dev/launcher.ts +713 -0
- package/src/dev/output.ts +72 -0
- package/src/dev/wire.ts +136 -0
- package/src/disk-assets.ts +8 -3
- package/src/files/archive.ts +2 -2
- package/src/files/job-names.ts +12 -0
- package/src/files/kinds.ts +19 -7
- package/src/files/scan.ts +3 -4
- package/src/files/streams.ts +3 -1
- package/src/implement/anonymous.ts +11 -1
- package/src/implement/boot.ts +70 -7
- package/src/implement/bounded.ts +21 -2
- package/src/implement/capability.ts +18 -3
- package/src/implement/dispatch.ts +251 -147
- package/src/implement/egress.ts +341 -8
- package/src/implement/failures.ts +17 -1
- package/src/implement/faults.ts +1 -0
- package/src/implement/index.ts +1 -0
- package/src/implement/invocation.ts +18 -9
- package/src/implement/principal.ts +25 -2
- package/src/implement/terminals.ts +93 -0
- package/src/implement/workload.ts +24 -13
- package/src/jobs/report.ts +5 -1
- package/src/jobs/runner.ts +40 -19
- package/src/jobs/schedule.ts +7 -1
- package/src/jobs/scrub.ts +14 -1
- package/src/spa-fallback.ts +12 -0
- package/src/testing/endpoint-access.ts +307 -152
- package/src/testing/isolation.ts +8 -1
- package/src/testing/kit.ts +20 -2
- package/src/testing/proxy.ts +5 -5
- package/types/src/agent-skill.d.ts +29 -0
- package/types/src/build-recipe.d.ts +70 -0
- package/types/src/build.d.ts +29 -0
- package/types/src/bun.d.ts +66 -0
- package/types/src/client/caller.d.ts +19 -0
- package/types/src/client/contract.d.ts +131 -0
- package/types/src/client/error-reporting.d.ts +66 -0
- package/types/src/client/index.d.ts +4 -0
- package/types/src/client/rpc.d.ts +14 -0
- package/types/src/client/share.d.ts +2 -0
- package/types/src/client/transport.d.ts +42 -0
- package/types/src/cloudflare/application.d.ts +42 -0
- package/types/src/cloudflare/bindings.d.ts +33 -0
- package/types/src/cloudflare/names.d.ts +11 -0
- package/types/src/cloudflare/ops.d.ts +46 -0
- package/types/src/cloudflare/r2-files.d.ts +9 -0
- package/types/src/cloudflare.d.ts +42 -0
- package/types/src/contract/app-id.d.ts +2 -0
- package/types/src/contract/artifact-schema.d.ts +8 -0
- package/types/src/contract/attachment.d.ts +41 -0
- package/types/src/contract/build.d.ts +12 -0
- package/types/src/contract/compile.d.ts +11 -0
- package/types/src/contract/connect.d.ts +51 -0
- package/types/src/contract/context.d.ts +510 -0
- package/types/src/contract/declaration.d.ts +307 -0
- package/types/src/contract/defaults.d.ts +208 -0
- package/types/src/contract/define.d.ts +376 -0
- package/types/src/contract/file.d.ts +44 -0
- package/types/src/contract/group.d.ts +282 -0
- package/types/src/contract/handled.d.ts +18 -0
- package/types/src/contract/index.d.ts +19 -0
- package/types/src/contract/kit.d.ts +211 -0
- package/types/src/contract/platform.d.ts +9 -0
- package/types/src/contract/problems.d.ts +14 -0
- package/types/src/contract/redirect.d.ts +25 -0
- package/types/src/contract/schema-from-shape.d.ts +10 -0
- package/types/src/contract/schema-shape.d.ts +5 -0
- package/types/src/contract/snapshot-source.d.ts +15 -0
- package/types/src/contract/token.d.ts +58 -0
- package/types/src/contract/vocabulary.d.ts +32 -0
- package/types/src/contract/webhook-binding.d.ts +24 -0
- package/types/src/contract/webhook-scheme.d.ts +23 -0
- package/types/src/dev/changes.d.ts +7 -0
- package/types/src/dev/contract-digest.d.ts +1 -0
- package/types/src/dev/index.d.ts +2 -0
- package/types/src/dev/launcher.d.ts +32 -0
- package/types/src/dev/output.d.ts +28 -0
- package/types/src/dev/wire.d.ts +126 -0
- package/types/src/disk-assets.d.ts +6 -0
- package/types/src/errors.d.ts +46 -0
- package/types/src/fetch.d.ts +7 -0
- package/types/src/files/archive.d.ts +26 -0
- package/types/src/files/clamscan.d.ts +32 -0
- package/types/src/files/handle.d.ts +106 -0
- package/types/src/files/job-names.d.ts +9 -0
- package/types/src/files/kinds.d.ts +101 -0
- package/types/src/files/scan.d.ts +78 -0
- package/types/src/files/sniff.d.ts +7 -0
- package/types/src/files/streams.d.ts +17 -0
- package/types/src/files/treatment.d.ts +18 -0
- package/types/src/from-row.d.ts +20 -0
- package/types/src/honeypot.d.ts +2 -0
- package/types/src/implement/anonymous.d.ts +28 -0
- package/types/src/implement/attachment.d.ts +14 -0
- package/types/src/implement/boot.d.ts +120 -0
- package/types/src/implement/bounded.d.ts +32 -0
- package/types/src/implement/browser-config.d.ts +19 -0
- package/types/src/implement/capability.d.ts +144 -0
- package/types/src/implement/challenge.d.ts +75 -0
- package/types/src/implement/client-address.d.ts +11 -0
- package/types/src/implement/connect.d.ts +67 -0
- package/types/src/implement/crashes.d.ts +31 -0
- package/types/src/implement/dispatch.d.ts +221 -0
- package/types/src/implement/egress.d.ts +49 -0
- package/types/src/implement/environment.d.ts +14 -0
- package/types/src/implement/failures.d.ts +39 -0
- package/types/src/implement/faults.d.ts +45 -0
- package/types/src/implement/headers.d.ts +7 -0
- package/types/src/implement/implement.d.ts +58 -0
- package/types/src/implement/index.d.ts +25 -0
- package/types/src/implement/invocation.d.ts +125 -0
- package/types/src/implement/membership.d.ts +201 -0
- package/types/src/implement/multipart.d.ts +36 -0
- package/types/src/implement/params.d.ts +7 -0
- package/types/src/implement/principal.d.ts +57 -0
- package/types/src/implement/project.d.ts +11 -0
- package/types/src/implement/public.d.ts +61 -0
- package/types/src/implement/serve.d.ts +25 -0
- package/types/src/implement/terminals.d.ts +33 -0
- package/types/src/implement/tokens.d.ts +49 -0
- package/types/src/implement/types.d.ts +243 -0
- package/types/src/implement/urls.d.ts +15 -0
- package/types/src/implement/verifiers.d.ts +19 -0
- package/types/src/implement/webhook.d.ts +55 -0
- package/types/src/implement/workload.d.ts +56 -0
- package/types/src/implement/xlsx.d.ts +6 -0
- package/types/src/index.d.ts +12 -0
- package/types/src/jobs/exception.d.ts +40 -0
- package/types/src/jobs/mail.d.ts +67 -0
- package/types/src/jobs/report.d.ts +56 -0
- package/types/src/jobs/runner.d.ts +154 -0
- package/types/src/jobs/schedule.d.ts +17 -0
- package/types/src/jobs/scrub.d.ts +12 -0
- package/types/src/jobs/types.d.ts +98 -0
- package/types/src/react/caller.d.ts +30 -0
- package/types/src/react/errors.d.ts +40 -0
- package/types/src/react/hooks.d.ts +110 -0
- package/types/src/react/index.d.ts +5 -0
- package/types/src/react/provider.d.ts +58 -0
- package/types/src/react/store.d.ts +68 -0
- package/types/src/react/testing.d.ts +28 -0
- package/types/src/react/types.d.ts +33 -0
- package/types/src/rpc/builder.d.ts +52 -0
- package/types/src/rpc/contract.d.ts +23 -0
- package/types/src/rpc/dispatcher.d.ts +11 -0
- package/types/src/rpc/types.d.ts +62 -0
- package/types/src/service/define.d.ts +42 -0
- package/types/src/service/index.d.ts +3 -0
- package/types/src/service/serve.d.ts +28 -0
- package/types/src/spa-fallback.d.ts +2 -0
- package/types/src/standard-schema.d.ts +55 -0
- package/types/src/testing/caller.d.ts +95 -0
- package/types/src/testing/challenge.d.ts +11 -0
- package/types/src/testing/dev.d.ts +97 -0
- package/types/src/testing/endpoint-access.d.ts +131 -0
- package/types/src/testing/index.d.ts +12 -0
- package/types/src/testing/isolation.d.ts +3 -0
- package/types/src/testing/kit.d.ts +187 -0
- package/types/src/testing/membership.d.ts +132 -0
- package/types/src/testing/policy-isolation.d.ts +113 -0
- package/types/src/testing/preload.d.ts +1 -0
- package/types/src/testing/proxy.d.ts +40 -0
- package/types/src/testing/runner.d.ts +8 -0
- package/types/src/testing/sample-file.d.ts +5 -0
- package/types/src/testing/status.d.ts +4 -0
- package/types/src/undumpable.d.ts +5 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// The build, init and start of a contract application in a Bun container, as data (ADR-0043): the steps and their
|
|
2
|
+
// order are the platform's, never the repository's. A provider renders the recipe into its own build form, with its own
|
|
3
|
+
// paths, file writes and scheduler. PURE — no network, no filesystem.
|
|
4
|
+
//
|
|
5
|
+
// PLATFORM PROTOCOL, not framework API: the platform renders this with ITS copy for applications of older releases
|
|
6
|
+
// (ADR-0062), so the commands, script names and job names an application must accept change only additively. Never
|
|
7
|
+
// render the application's own copy.
|
|
8
|
+
|
|
9
|
+
import { APPLICATION_SERVER_MAX_HEADER_BYTES, FRAMEWORK_RELEASE_PATTERN } from '@fabrika/auth-core'
|
|
10
|
+
import { CONTRACT_APP_ID_PATTERN } from './contract/app-id.js'
|
|
11
|
+
import { FILE_SCAN_JOB, FILE_SCAN_SCHEDULE, FILE_SWEEP_JOB, FILE_SWEEP_SCHEDULE } from './files/job-names.js'
|
|
12
|
+
|
|
13
|
+
export { CONTRACT_APP_ID_PATTERN } from './contract/app-id.js'
|
|
14
|
+
|
|
15
|
+
export { FILE_SCAN_JOB, FILE_SWEEP_JOB } from './files/job-names.js'
|
|
16
|
+
|
|
17
|
+
/** The TypeScript release of the platform's type check; the application's own `typescript` is not run. */
|
|
18
|
+
export const PLATFORM_TYPESCRIPT_VERSION = '5.9.3'
|
|
19
|
+
|
|
20
|
+
/** The CLI a generated script runs, relative to the application root. */
|
|
21
|
+
const FABRIKA_BIN = './node_modules/.bin/fabrika'
|
|
22
|
+
const MIGRATE_SCRIPT = 'fabrika/migrate.generated.sh'
|
|
23
|
+
const VERIFY_SCRIPT = 'fabrika/verify.generated.sh'
|
|
24
|
+
const START_SCRIPT = 'fabrika/start.generated.sh'
|
|
25
|
+
const SERVE: readonly string[] = ['bun', `--max-http-header-size=${APPLICATION_SERVER_MAX_HEADER_BYTES}`, 'fabrika/entry.generated.ts']
|
|
26
|
+
const MIGRATE: readonly string[] = [FABRIKA_BIN, 'db', 'migrate']
|
|
27
|
+
const PLATFORM_TSCONFIG = 'fabrika/tsconfig.platform.json'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The compiler options of the platform's type check; the application's own `tsconfig.json` and its `paths` are not read.
|
|
31
|
+
* It covers the SPA under `web/` too: Bun bundles the SPA without checking its types.
|
|
32
|
+
*/
|
|
33
|
+
const PLATFORM_TSCONFIG_CONTENTS = JSON.stringify({
|
|
34
|
+
compilerOptions: {
|
|
35
|
+
target: 'ES2022',
|
|
36
|
+
module: 'ESNext',
|
|
37
|
+
moduleResolution: 'bundler',
|
|
38
|
+
lib: ['ES2022', 'DOM', 'DOM.Iterable'],
|
|
39
|
+
jsx: 'react-jsx',
|
|
40
|
+
types: ['bun'],
|
|
41
|
+
strict: true,
|
|
42
|
+
noUncheckedIndexedAccess: true,
|
|
43
|
+
noImplicitOverride: true,
|
|
44
|
+
forceConsistentCasingInFileNames: true,
|
|
45
|
+
skipLibCheck: true,
|
|
46
|
+
resolveJsonModule: true,
|
|
47
|
+
isolatedModules: true,
|
|
48
|
+
noEmit: true,
|
|
49
|
+
},
|
|
50
|
+
include: ['../fabrika.contract.ts', '../src', '../web', 'entry.generated.ts'],
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const INSTALL = 'bun install --frozen-lockfile --ignore-scripts'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What `@fabrika/db/testing` prints after the label of a test file it skips without `FABRIKA_TEST_POSTGRES_URL`. Every
|
|
57
|
+
* framework release prints it, so the platform counts it for any application's release.
|
|
58
|
+
*/
|
|
59
|
+
const DATABASE_TEST_SKIP_MARKER = ' skipped: set FABRIKA_TEST_POSTGRES_URL to run the Postgres-backed tests'
|
|
60
|
+
|
|
61
|
+
/** The prefix of the line the tests command prints after the test run. */
|
|
62
|
+
const TESTS_REPORT_PREFIX = 'fabrika-tests:'
|
|
63
|
+
|
|
64
|
+
/** One `bun -e "…"` running `script`, which may hold none of the characters a double-quoted shell word expands. */
|
|
65
|
+
const bunEvalCommand = (script: readonly string[]): string => {
|
|
66
|
+
const source = script.join(' ')
|
|
67
|
+
if (/["$`\\!]/.test(source)) throw new Error('a generated bun -e script must not contain a quote, an expansion or a backslash')
|
|
68
|
+
return `bun -e "${source}"`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The build has no database, so every Postgres-backed test file skips and the run still passes. This command runs the
|
|
72
|
+
// tests as before and then says how many files did not run, so a green test step does not read as a tested database.
|
|
73
|
+
const TESTS_COMMAND = bunEvalCommand([
|
|
74
|
+
"const child = Bun.spawn([process.execPath, 'test', '--isolate', '--pass-with-no-tests'], { stdout: 'inherit', stderr: 'pipe' });",
|
|
75
|
+
'const decoder = new TextDecoder();',
|
|
76
|
+
"let output = '';",
|
|
77
|
+
'for await (const chunk of child.stderr) { process.stderr.write(chunk); output += decoder.decode(chunk, { stream: true }) }',
|
|
78
|
+
'const code = await child.exited;',
|
|
79
|
+
`const marker = '${DATABASE_TEST_SKIP_MARKER}';`,
|
|
80
|
+
'const skipped = output.split(String.fromCharCode(10)).filter((line) => line.includes(marker)).map((line) => line.slice(0, line.indexOf(marker)).trim());',
|
|
81
|
+
`const prefix = '${TESTS_REPORT_PREFIX}';`,
|
|
82
|
+
"console.warn(skipped.length === 0 ? prefix + ' no Postgres-backed test file skipped' : prefix + ' ' + skipped.length + ' Postgres-backed test files did not run, because the build has no database: ' + skipped.join(', '));",
|
|
83
|
+
'process.exit(code);',
|
|
84
|
+
])
|
|
85
|
+
|
|
86
|
+
/** A `c.cron` job name, which a crontab line carries unquoted. */
|
|
87
|
+
export const CONTRACT_JOB_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/
|
|
88
|
+
/** Five cron fields of digits and `*`, `/`, `,`, `-` only. */
|
|
89
|
+
export const CRON_SCHEDULE_PATTERN = /^[0-9*/,-]+(?: [0-9*/,-]+){4}$/
|
|
90
|
+
|
|
91
|
+
/** What one contract application's recipe depends on. `contractRecipe` refuses a value that a shell would read as more. */
|
|
92
|
+
export interface ContractRecipeInput {
|
|
93
|
+
readonly appId: string
|
|
94
|
+
/** The `@fabrika/check` release whose reader read the application (ADR-0062). */
|
|
95
|
+
readonly checkRelease: string
|
|
96
|
+
/** The absolute directory the build runs in: the checker reads the source there. */
|
|
97
|
+
readonly sourceRoot: string
|
|
98
|
+
/** The absolute directory the built application runs from: each generated script starts there. */
|
|
99
|
+
readonly runtimeRoot: string
|
|
100
|
+
/** With a database, every container migrates and checks its schema before the server starts. */
|
|
101
|
+
readonly database: boolean
|
|
102
|
+
/** Whether the build uploads its source maps, last and never failing the build. */
|
|
103
|
+
readonly sourceMaps: boolean
|
|
104
|
+
readonly jobs: readonly { readonly name: string; readonly schedule: string }[]
|
|
105
|
+
/** The contract declares a file collection: the sweep of removed files is scheduled. */
|
|
106
|
+
readonly files: boolean
|
|
107
|
+
/** A file collection scans its uploads: the scan pass is scheduled. */
|
|
108
|
+
readonly scan: boolean
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One build step: a command run in the source root, or a generated file written there, line by line. */
|
|
112
|
+
export type ContractBuildStep = { readonly run: string } | { readonly write: string; readonly lines: readonly string[] }
|
|
113
|
+
|
|
114
|
+
/** One check of the build: a command run in the source root that fails the build. */
|
|
115
|
+
export interface ContractCheck {
|
|
116
|
+
readonly name: 'tests' | 'tsc' | 'fabrika-check'
|
|
117
|
+
readonly command: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface ContractRecipe {
|
|
121
|
+
/** The steps after the authored SPA commands, in order. */
|
|
122
|
+
readonly build: readonly ContractBuildStep[]
|
|
123
|
+
/** The checks among `build`, in its order. */
|
|
124
|
+
readonly checks: readonly ContractCheck[]
|
|
125
|
+
/** The generated scripts every container runs before `start`, in order; any failure stops the activation. */
|
|
126
|
+
readonly init: readonly string[]
|
|
127
|
+
/** The generated script that starts the server, which only `exec`s it. */
|
|
128
|
+
readonly start: string
|
|
129
|
+
/** The command the start script `exec`s, run from the runtime root. */
|
|
130
|
+
readonly serve: readonly string[]
|
|
131
|
+
/** The command that migrates the application's database, run from the runtime root. */
|
|
132
|
+
readonly migrate: readonly string[]
|
|
133
|
+
/** The jobs to schedule, each on one container; a command runs from the runtime root. */
|
|
134
|
+
readonly jobs: readonly { readonly schedule: string; readonly command: string }[]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The recipe of one contract application. The application's tests run first: they are authored code, so everything
|
|
139
|
+
* after them is reinstalled, regenerated or checked again. They run `--isolate`, as the application skill runs them: a
|
|
140
|
+
* test file's DOM globals otherwise leak into the next file. `tsc` runs before the checker, as in the skill's loop: the
|
|
141
|
+
* usual quick fix of a type error is a cast or an `any`, which only the checker refuses, so the checker is the last word
|
|
142
|
+
* on the code. The build has no database, so the schema checks run as init scripts, after the migrations and before the
|
|
143
|
+
* readiness window opens.
|
|
144
|
+
*/
|
|
145
|
+
export function contractRecipe(input: ContractRecipeInput): ContractRecipe {
|
|
146
|
+
const { appId, checkRelease, sourceRoot, runtimeRoot } = input
|
|
147
|
+
if (!CONTRACT_APP_ID_PATTERN.test(appId)) throw new Error('a contract application id must match [a-z0-9][a-z0-9-]*')
|
|
148
|
+
if (!FRAMEWORK_RELEASE_PATTERN.test(checkRelease)) throw new Error('the checker release must be a release version')
|
|
149
|
+
if (input.jobs.some((job) => !CONTRACT_JOB_NAME_PATTERN.test(job.name) || !CRON_SCHEDULE_PATTERN.test(job.schedule))) {
|
|
150
|
+
throw new Error('a job name or schedule is not one a generated crontab line may carry')
|
|
151
|
+
}
|
|
152
|
+
const script = (lines: readonly string[]): readonly string[] => ['#!/bin/sh', 'set -eu', `cd ${runtimeRoot}`, ...lines]
|
|
153
|
+
const init = input.database
|
|
154
|
+
? [
|
|
155
|
+
{ path: MIGRATE_SCRIPT, lines: script([`FABRIKA_APP_ID=${appId} exec ${MIGRATE.join(' ')}`]) },
|
|
156
|
+
// The committed schema types must match the migrated schema, and every generated isolation test must pass on a
|
|
157
|
+
// throwaway schema.
|
|
158
|
+
{
|
|
159
|
+
path: VERIFY_SCRIPT,
|
|
160
|
+
lines: script([`FABRIKA_APP_ID=${appId} ${FABRIKA_BIN} db codegen --verify`, `FABRIKA_APP_ID=${appId} exec ${FABRIKA_BIN} db test-isolation`]),
|
|
161
|
+
},
|
|
162
|
+
]
|
|
163
|
+
: []
|
|
164
|
+
const tests: ContractCheck = { name: 'tests', command: TESTS_COMMAND }
|
|
165
|
+
const tsc: ContractCheck = {
|
|
166
|
+
name: 'tsc',
|
|
167
|
+
command: `cd /tmp && bun x --bun -p typescript@${PLATFORM_TYPESCRIPT_VERSION} tsc -p ${sourceRoot}/${PLATFORM_TSCONFIG}`,
|
|
168
|
+
}
|
|
169
|
+
const check: ContractCheck = {
|
|
170
|
+
name: 'fabrika-check',
|
|
171
|
+
command: `cd /tmp && bun x --bun -p @fabrika/check@${checkRelease} fabrika-check ${sourceRoot}`,
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
build: [
|
|
175
|
+
{ run: INSTALL },
|
|
176
|
+
{ run: tests.command },
|
|
177
|
+
{ run: `rm -rf node_modules && ${INSTALL}` },
|
|
178
|
+
{ run: `bun -e "await import('@fabrika/app/build').then((build) => build.writeGeneratedEntry('.', 'bun'))"` },
|
|
179
|
+
{ write: PLATFORM_TSCONFIG, lines: [PLATFORM_TSCONFIG_CONTENTS] },
|
|
180
|
+
{ run: tsc.command },
|
|
181
|
+
{ run: check.command },
|
|
182
|
+
...init.map((generated) => ({ write: generated.path, lines: generated.lines })),
|
|
183
|
+
{ write: START_SCRIPT, lines: script([`exec ${SERVE.join(' ')}`]) },
|
|
184
|
+
// After every check, so a map is uploaded only for a build that can deploy; the report line says how it went.
|
|
185
|
+
...(input.sourceMaps ? [{ run: `cd /tmp && bun x --bun -p @fabrika/check@${checkRelease} fabrika-source-maps ${sourceRoot} || true` }] : []),
|
|
186
|
+
],
|
|
187
|
+
checks: [tests, tsc, check],
|
|
188
|
+
init: init.map((generated) => generated.path),
|
|
189
|
+
start: START_SCRIPT,
|
|
190
|
+
serve: SERVE,
|
|
191
|
+
migrate: MIGRATE,
|
|
192
|
+
jobs: [
|
|
193
|
+
...input.jobs.map((job) => ({ schedule: job.schedule, command: `${FABRIKA_BIN} job run ${job.name}` })),
|
|
194
|
+
...(input.files ? [{ schedule: FILE_SWEEP_SCHEDULE, command: `${FABRIKA_BIN} job run ${FILE_SWEEP_JOB}` }] : []),
|
|
195
|
+
...(input.scan ? [{ schedule: FILE_SCAN_SCHEDULE, command: `${FABRIKA_BIN} job run ${FILE_SCAN_JOB}` }] : []),
|
|
196
|
+
],
|
|
197
|
+
}
|
|
198
|
+
}
|
package/src/build.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the only module that reads `process.env` and the only caller of `boot`, through the runtime adapter, and it imports
|
|
3
3
|
// the application only after the adapter took the framework's variables out of the process.
|
|
4
4
|
|
|
5
|
-
import { mkdir, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
6
6
|
import { dirname, join } from 'node:path'
|
|
7
7
|
|
|
8
8
|
/** An application root with this file is a contract application. The paths equal `@fabrika/check`'s layout. */
|
|
@@ -14,8 +14,24 @@ export const GENERATED_ENTRY_FILE = 'fabrika/entry.generated.ts'
|
|
|
14
14
|
/** The module whose default export is `serve(contract, …)`, relative to the application root. */
|
|
15
15
|
export const IMPLEMENTATION_FILE = 'src/app.ts'
|
|
16
16
|
|
|
17
|
+
/** The application's migrations as a module, relative to the application root: a Worker reads no file system. */
|
|
18
|
+
export const GENERATED_MIGRATIONS_FILE = 'fabrika/migrations.generated.ts'
|
|
19
|
+
|
|
20
|
+
/** Where an application keeps its migrations, relative to its root; `fabrika db new` writes there. */
|
|
21
|
+
export const MIGRATIONS_DIRECTORY = 'db/migrations'
|
|
22
|
+
|
|
17
23
|
export type EntryRuntime = 'bun' | 'cloudflare'
|
|
18
24
|
|
|
25
|
+
export * from './build-recipe.js'
|
|
26
|
+
|
|
27
|
+
// In a module of their own, which the Worker imports without this one's file system.
|
|
28
|
+
export {
|
|
29
|
+
CLOUDFLARE_APPLICATION_BINDING,
|
|
30
|
+
CLOUDFLARE_APPLICATION_CLASS,
|
|
31
|
+
CLOUDFLARE_ASSETS_BINDING,
|
|
32
|
+
CLOUDFLARE_FILES_BINDING,
|
|
33
|
+
} from './cloudflare/names.js'
|
|
34
|
+
|
|
19
35
|
const HEADER = '// @generated by `fabrika app build` from src/app.ts. Do not edit: every build rewrites it.'
|
|
20
36
|
|
|
21
37
|
/** The entrypoint source for one runtime. */
|
|
@@ -35,19 +51,59 @@ export function generatedEntry(runtime: EntryRuntime): string {
|
|
|
35
51
|
return [
|
|
36
52
|
HEADER,
|
|
37
53
|
"import { createContractWorker, markProductionProcess } from '@fabrika/app/cloudflare'",
|
|
54
|
+
"import { migrations } from './migrations.generated'",
|
|
38
55
|
'',
|
|
39
|
-
|
|
56
|
+
"export { FabrikaApplication } from '@fabrika/app/cloudflare'",
|
|
57
|
+
'',
|
|
58
|
+
'// The test kits refuse to load from here on; the object imports the application only after the framework took its variables.',
|
|
40
59
|
'markProductionProcess()',
|
|
41
|
-
"export default createContractWorker(() => import('../src/app'))",
|
|
60
|
+
"export default createContractWorker(() => import('../src/app'), { migrations })",
|
|
42
61
|
'',
|
|
43
62
|
].join('\n')
|
|
44
63
|
}
|
|
45
64
|
}
|
|
46
65
|
|
|
47
|
-
/** Writes the entrypoint under `root` and returns its path. */
|
|
66
|
+
/** Writes the entrypoint under `root` and returns its path; the Cloudflare one also writes the migrations module it imports. */
|
|
48
67
|
export async function writeGeneratedEntry(root: string, runtime: EntryRuntime): Promise<string> {
|
|
49
68
|
const path = join(root, GENERATED_ENTRY_FILE)
|
|
50
69
|
await mkdir(dirname(path), { recursive: true })
|
|
51
70
|
await writeFile(path, generatedEntry(runtime))
|
|
71
|
+
if (runtime === 'cloudflare') {
|
|
72
|
+
await writeGeneratedMigrations(root)
|
|
73
|
+
}
|
|
74
|
+
return path
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The application's migrations under `root`, every `.sql` file of `db/migrations` by name; none without the directory. */
|
|
78
|
+
export async function applicationMigrations(root: string): Promise<readonly { readonly name: string; readonly sql: string }[]> {
|
|
79
|
+
const directory = join(root, MIGRATIONS_DIRECTORY)
|
|
80
|
+
let names: string[]
|
|
81
|
+
try {
|
|
82
|
+
names = await readdir(directory)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
85
|
+
return []
|
|
86
|
+
}
|
|
87
|
+
throw error
|
|
88
|
+
}
|
|
89
|
+
const files = names.filter((name) => name.endsWith('.sql')).sort((left, right) => left < right ? -1 : left > right ? 1 : 0)
|
|
90
|
+
return await Promise.all(files.map(async (name) => ({ name, sql: await readFile(join(directory, name), 'utf8') })))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The migrations module source: the files' names and texts, verbatim, so their checksums stay the files'. */
|
|
94
|
+
export function generatedMigrations(migrations: readonly { readonly name: string; readonly sql: string }[]): string {
|
|
95
|
+
return [
|
|
96
|
+
`// @generated by \`fabrika app build\` from ${MIGRATIONS_DIRECTORY}. Do not edit: every build rewrites it.`,
|
|
97
|
+
'',
|
|
98
|
+
`export const migrations: readonly { readonly name: string; readonly sql: string }[] = ${JSON.stringify(migrations, null, '\t')}`,
|
|
99
|
+
'',
|
|
100
|
+
].join('\n')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Writes the migrations module under `root` and returns its path. */
|
|
104
|
+
export async function writeGeneratedMigrations(root: string): Promise<string> {
|
|
105
|
+
const path = join(root, GENERATED_MIGRATIONS_FILE)
|
|
106
|
+
await mkdir(dirname(path), { recursive: true })
|
|
107
|
+
await writeFile(path, generatedMigrations(await applicationMigrations(root)))
|
|
52
108
|
return path
|
|
53
109
|
}
|
package/src/bun.ts
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
// contract application is served through `serveContract`, from the generated
|
|
6
6
|
// entrypoint.
|
|
7
7
|
|
|
8
|
-
import { HttpIamMembership } from '@fabrika/auth'
|
|
9
|
-
import type { Database
|
|
8
|
+
import { HttpIamMembership, HttpIamTerminals } from '@fabrika/auth'
|
|
9
|
+
import type { Database } from '@fabrika/db'
|
|
10
10
|
import { isScopedDatabase } from '@fabrika/db/framework'
|
|
11
|
+
import type { PostgresOptions } from '@fabrika/db/postgres'
|
|
11
12
|
import type { EmailProviderRegistry } from '@fabrika/email'
|
|
12
13
|
import { directoryEmailProvider } from '@fabrika/email/directory'
|
|
13
14
|
import { parseJwks } from '@fabrika/jwks'
|
|
14
15
|
import type { FileObjectStore } from '@fabrika/platform'
|
|
15
16
|
import { applicationRuntimeUrl, S3BlobStore } from '@fabrika/platform-node'
|
|
17
|
+
import { lookup } from 'node:dns/promises'
|
|
16
18
|
import { diskAssets } from './disk-assets.js'
|
|
17
19
|
import type { FetchService, RequestExecutionContext } from './fetch.js'
|
|
18
20
|
import { clamscanScanner } from './files/clamscan.js'
|
|
@@ -117,7 +119,7 @@ export interface ContractServer {
|
|
|
117
119
|
|
|
118
120
|
/**
|
|
119
121
|
* What the adapter takes from `@fabrika/db` before the application loads, so no application module can stand in for
|
|
120
|
-
* it: a replaced `
|
|
122
|
+
* it: a replaced `openPostgres` would receive the database URL.
|
|
121
123
|
*/
|
|
122
124
|
interface DataLayerOpeners {
|
|
123
125
|
readonly postgres: (options: PostgresOptions) => Database
|
|
@@ -126,8 +128,8 @@ interface DataLayerOpeners {
|
|
|
126
128
|
|
|
127
129
|
/** Loads `@fabrika/db` before the application: it captures its built-ins and freezes the Kysely compiler at load. */
|
|
128
130
|
async function loadDataLayer(): Promise<DataLayerOpeners> {
|
|
129
|
-
const {
|
|
130
|
-
return { postgres:
|
|
131
|
+
const [{ applicationSchema }, { openPostgres }] = await Promise.all([import('@fabrika/db'), import('@fabrika/db/postgres')])
|
|
132
|
+
return { postgres: openPostgres, applicationSchema }
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
/** How long a stopping process waits for the work its requests left running; a kicked run aborts at once. */
|
|
@@ -277,12 +279,22 @@ export function openFileStorage(framework: Readonly<Record<string, string>>): Fi
|
|
|
277
279
|
/**
|
|
278
280
|
* IAM's membership transport, when the platform gives the process `FABRIKA_IAM_RPC_URL` (IAM's public origin) and
|
|
279
281
|
* `FABRIKA_IAM_RPC_KEY` (this environment's membership transport key); without both, there is none, and `boot` refuses
|
|
280
|
-
* a contract that needs one.
|
|
282
|
+
* a contract that needs one. The identifications at a terminal travel on the same transport.
|
|
281
283
|
*/
|
|
282
|
-
function
|
|
284
|
+
function httpIamTransport(framework: Readonly<Record<string, string>>): { readonly origin: string; readonly key: string } | undefined {
|
|
283
285
|
const origin = framework['FABRIKA_IAM_RPC_URL']?.trim() ?? ''
|
|
284
286
|
const key = framework['FABRIKA_IAM_RPC_KEY']?.trim() ?? ''
|
|
285
|
-
return origin === '' || key === '' ? undefined :
|
|
287
|
+
return origin === '' || key === '' ? undefined : { origin, key }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function httpMembership(framework: Readonly<Record<string, string>>): HttpIamMembership | undefined {
|
|
291
|
+
const transport = httpIamTransport(framework)
|
|
292
|
+
return transport === undefined ? undefined : new HttpIamMembership(transport)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function httpTerminals(framework: Readonly<Record<string, string>>): HttpIamTerminals | undefined {
|
|
296
|
+
const transport = httpIamTransport(framework)
|
|
297
|
+
return transport === undefined ? undefined : new HttpIamTerminals(transport)
|
|
286
298
|
}
|
|
287
299
|
|
|
288
300
|
/** IAM's public token exchange, at the issuer every deploy names; `boot` reports a missing issuer itself. */
|
|
@@ -381,6 +393,7 @@ async function startContractServer(
|
|
|
381
393
|
const iam = issuerJwks(environment.framework['FABRIKA_IAM_ISSUER'])
|
|
382
394
|
const database = isScopedDatabase(app.database) ? openApplicationDatabase(data, environment.framework) : undefined
|
|
383
395
|
const membership = httpMembership(environment.framework)
|
|
396
|
+
const terminals = httpTerminals(environment.framework)
|
|
384
397
|
// Zerops is where a Bun application runs, and a namespace there is one private network (ADR-0022 rule 5).
|
|
385
398
|
const files = openFileStorage(environment.framework)
|
|
386
399
|
const workloadTokens = workloadTokensOf(environment.framework)
|
|
@@ -391,7 +404,9 @@ async function startContractServer(
|
|
|
391
404
|
database,
|
|
392
405
|
files,
|
|
393
406
|
membership,
|
|
407
|
+
terminals,
|
|
394
408
|
workloadTokens,
|
|
409
|
+
resolveHost,
|
|
395
410
|
requireProxySecret: true,
|
|
396
411
|
localEmailProviders: LOCAL_EMAIL_PROVIDERS,
|
|
397
412
|
connections: databaseConnections(environment.framework[DATABASE_CONNECTIONS]) ?? DEFAULT_DATABASE_CONNECTIONS,
|
|
@@ -419,6 +434,13 @@ async function startContractServer(
|
|
|
419
434
|
return { port: server.port ?? port, stop }
|
|
420
435
|
}
|
|
421
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Every address `hostname` resolves to, as the operating system's resolver answers Bun's own `fetch`: a cron job's public
|
|
439
|
+
* egress refuses a hop whose name resolves to any private one (ADR-0070).
|
|
440
|
+
*/
|
|
441
|
+
const resolveHost = async (hostname: string): Promise<readonly string[]> =>
|
|
442
|
+
(await lookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address)
|
|
443
|
+
|
|
422
444
|
/** The application module at `path`, imported when the loader runs; its default export must be what `serve` returned. */
|
|
423
445
|
export function implementationAt(path: string): ImplementationLoader {
|
|
424
446
|
return async () => {
|
|
@@ -454,6 +476,7 @@ export async function runContractJob(
|
|
|
454
476
|
try {
|
|
455
477
|
const workloadTokens = workloadTokensOf(environment.framework)
|
|
456
478
|
const membership = httpMembership(environment.framework)
|
|
479
|
+
const terminals = httpTerminals(environment.framework)
|
|
457
480
|
const files = openFileStorage(environment.framework)
|
|
458
481
|
return await boot(app, {
|
|
459
482
|
environment,
|
|
@@ -462,7 +485,9 @@ export async function runContractJob(
|
|
|
462
485
|
database,
|
|
463
486
|
files,
|
|
464
487
|
membership,
|
|
488
|
+
terminals,
|
|
465
489
|
workloadTokens,
|
|
490
|
+
resolveHost,
|
|
466
491
|
requireProxySecret: true,
|
|
467
492
|
localEmailProviders: LOCAL_EMAIL_PROVIDERS,
|
|
468
493
|
// ClamAV on PATH, as the provider's build installs it for a `scan: true` file kind; missing, the scan fails closed.
|