@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,352 @@
|
|
|
1
|
+
// The Durable Object an application environment runs in (ADR-0065): it migrates its SQLite, boots the application and
|
|
2
|
+
// serves every request and minute tick its Worker forwards. A plain class, so Bun and Lopata load it without
|
|
3
|
+
// `cloudflare:workers`.
|
|
4
|
+
|
|
5
|
+
import { FABRIKA_PROXY_SECRET, OPS_PREFIX, type OpsBootFailureContent, PROXY_SECRET_HEADER } from '@fabrika/auth-core'
|
|
6
|
+
import { isScopedDatabase } from '@fabrika/db/framework'
|
|
7
|
+
import { SQLITE_FRAMEWORK_MIGRATIONS } from '@fabrika/db/migrations-sqlite'
|
|
8
|
+
import { openSqlite, type SqliteStorage } from '@fabrika/db/sqlite'
|
|
9
|
+
import { migrateSqlite } from '@fabrika/db/sqlite-migrate'
|
|
10
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
11
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
12
|
+
import type { RequestExecutionContext } from '../fetch.js'
|
|
13
|
+
import { boot, type ContractApp } from '../implement/boot.js'
|
|
14
|
+
import { type CrashEvents, environmentCrashReporter, reportSurvivedCrashes } from '../implement/crashes.js'
|
|
15
|
+
import { proxySecretDigest } from '../implement/dispatch.js'
|
|
16
|
+
import { type CapturedEnvironment, captureEnvironment } from '../implement/environment.js'
|
|
17
|
+
import type { ContractImplementation } from '../implement/implement.js'
|
|
18
|
+
import type { CrashReporter } from '../jobs/report.js'
|
|
19
|
+
import { isDueAt } from '../jobs/schedule.js'
|
|
20
|
+
import { environmentScrubber, type Scrubber } from '../jobs/scrub.js'
|
|
21
|
+
import { bindingAssets, bindingKeySource, bindingMembership, bindingTerminals, bindingWorkloadTokens, connectBindings } from './bindings.js'
|
|
22
|
+
import { CLOUDFLARE_ASSETS_BINDING, CLOUDFLARE_FILES_BINDING } from './names.js'
|
|
23
|
+
import { type OpsAccess, opsAccess, type OpsStorage, serveOps } from './ops.js'
|
|
24
|
+
import { r2FileObjectStore } from './r2-files.js'
|
|
25
|
+
|
|
26
|
+
/** The application module, imported only after the environment was captured: the generated entrypoint's `import()`. */
|
|
27
|
+
export type ImplementationLoader = () => Promise<{ readonly default: ContractImplementation }>
|
|
28
|
+
|
|
29
|
+
/** One of the application's SQLite migrations, as `fabrika app build` bundles them from `db/migrations`. */
|
|
30
|
+
export interface ApplicationMigration {
|
|
31
|
+
readonly name: string
|
|
32
|
+
readonly sql: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ContractWorkerOptions {
|
|
36
|
+
/** The application's own SQLite migrations, applied as the bundle `app` after the framework's `fabrika`. */
|
|
37
|
+
readonly migrations?: readonly ApplicationMigration[]
|
|
38
|
+
/** Where the isolate hears of an unhandled rejection; the global `unhandledrejection` event by default. */
|
|
39
|
+
readonly rejections?: CrashEvents
|
|
40
|
+
/** What posts a report to Operations' ingest; `fetch` by default. */
|
|
41
|
+
readonly operationsFetch?: (request: Request) => Promise<Response>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Marks the Worker's minute tick, with its `scheduledTime`. The Worker's `fetch` removes it from every request it
|
|
46
|
+
* forwards, so only its own `scheduled` handler reaches the tick.
|
|
47
|
+
*/
|
|
48
|
+
export const TICK_HEADER = 'X-Fabrika-Tick'
|
|
49
|
+
|
|
50
|
+
/** What the object needs of its `ctx.storage`; a Durable Object's `DurableObjectStorage` fits it. */
|
|
51
|
+
export type ApplicationStorage = SqliteStorage & OpsStorage
|
|
52
|
+
|
|
53
|
+
/** What the object needs of its `DurableObjectState`. */
|
|
54
|
+
export interface ApplicationState {
|
|
55
|
+
readonly storage: ApplicationStorage
|
|
56
|
+
blockConcurrencyWhile<T>(callback: () => Promise<T>): Promise<T>
|
|
57
|
+
waitUntil(promise: Promise<unknown>): void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The framework's bindings: IAM's application entrypoint and the Workers Assets of the SPA. No handler sees them. */
|
|
61
|
+
const FRAMEWORK_BINDINGS: ReadonlySet<string> = new Set(['IAM', CLOUDFLARE_ASSETS_BINDING])
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The application this isolate booted, once: its module is loaded once, and its `scoped(tables)` layer binds once, to
|
|
65
|
+
* the storage of the object whose event runs (`objectStorage`), never to one database.
|
|
66
|
+
*/
|
|
67
|
+
interface Booted {
|
|
68
|
+
readonly app: ContractApp
|
|
69
|
+
/** Whether the application has a data layer, which every object instance migrates in its own storage. */
|
|
70
|
+
readonly migrates: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface ApplicationSetup {
|
|
74
|
+
readonly load: ImplementationLoader
|
|
75
|
+
readonly options: ContractWorkerOptions
|
|
76
|
+
booted: Promise<Booted> | null
|
|
77
|
+
/** Whether the current streak of failed attachments was reported; an attachment that succeeds ends the streak. */
|
|
78
|
+
failureReported: boolean
|
|
79
|
+
watchingRejections: boolean
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let registered: ApplicationSetup | null = null
|
|
83
|
+
|
|
84
|
+
/** The storage of the object whose event runs; the work an event hands to `waitUntil` keeps it. */
|
|
85
|
+
const objectStorage = new AsyncLocalStorage<ApplicationStorage>()
|
|
86
|
+
|
|
87
|
+
function eventStorage(): ApplicationStorage {
|
|
88
|
+
const storage = objectStorage.getStore()
|
|
89
|
+
if (storage === undefined) {
|
|
90
|
+
throw new Error('a statement ran outside any event of an application object: the framework runs every event in its object scope')
|
|
91
|
+
}
|
|
92
|
+
return storage
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What the object classes of this isolate boot: the generated entrypoint's `createContractWorker` registers it. */
|
|
96
|
+
export function registerApplication(load: ImplementationLoader, options: ContractWorkerOptions): void {
|
|
97
|
+
registered = { load, options, booted: null, failureReported: false, watchingRejections: false }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The isolate's `unhandledrejection` event. Listening leaves the runtime's own handling, its log line, in place. */
|
|
101
|
+
function globalRejectionEvents(): CrashEvents {
|
|
102
|
+
return {
|
|
103
|
+
listen(listener) {
|
|
104
|
+
const rejection = (event: Event) => listener('unhandled_rejection', Reflect.get(event, 'reason'))
|
|
105
|
+
globalThis.addEventListener('unhandledrejection', rejection)
|
|
106
|
+
return () => globalThis.removeEventListener('unhandledrejection', rejection)
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The framework's tables, then the application's, each migration once: a booted storage applies nothing. */
|
|
112
|
+
async function migrate(storage: ApplicationStorage, migrations: readonly ApplicationMigration[]): Promise<void> {
|
|
113
|
+
await migrateSqlite({ exec: (query, ...bindings) => storage.sql.exec(query, ...bindings), transactionSync: (fn) => storage.transactionSync(fn) }, [
|
|
114
|
+
{ bundle: 'fabrika', migrations: SQLITE_FRAMEWORK_MIGRATIONS },
|
|
115
|
+
{ bundle: 'app', migrations },
|
|
116
|
+
])
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function bootApplication(
|
|
120
|
+
setup: ApplicationSetup,
|
|
121
|
+
state: ApplicationState,
|
|
122
|
+
env: Readonly<Record<string, unknown>>,
|
|
123
|
+
environment: CapturedEnvironment,
|
|
124
|
+
crashes: CrashReporter | null,
|
|
125
|
+
): Promise<Booted> {
|
|
126
|
+
const { default: implementation } = await setup.load()
|
|
127
|
+
const migrates = isScopedDatabase(implementation.database)
|
|
128
|
+
if (migrates) {
|
|
129
|
+
await migrate(state.storage, setup.options.migrations ?? [])
|
|
130
|
+
}
|
|
131
|
+
const database = migrates
|
|
132
|
+
? openSqlite({
|
|
133
|
+
sql: { exec: (query, ...bindings) => eventStorage().sql.exec(query, ...bindings) },
|
|
134
|
+
transaction: (closure) => eventStorage().transaction(closure),
|
|
135
|
+
transactionSync: (closure) => eventStorage().transactionSync(closure),
|
|
136
|
+
})
|
|
137
|
+
: undefined
|
|
138
|
+
const app = boot(implementation, {
|
|
139
|
+
environment,
|
|
140
|
+
iam: bindingKeySource(env['IAM']),
|
|
141
|
+
assets: bindingAssets(env[CLOUDFLARE_ASSETS_BINDING]),
|
|
142
|
+
membership: bindingMembership(env['IAM']),
|
|
143
|
+
terminals: bindingTerminals(env['IAM']),
|
|
144
|
+
workloadTokens: bindingWorkloadTokens(env['IAM']),
|
|
145
|
+
connectBindings: connectBindings(env),
|
|
146
|
+
database,
|
|
147
|
+
files: r2FileObjectStore(env[CLOUDFLARE_FILES_BINDING]),
|
|
148
|
+
// The Worker forwards only with the proxy secret, which the object checked; `boot` checks it again.
|
|
149
|
+
requireProxySecret: true,
|
|
150
|
+
// The proxy Worker sets `X-Fabrika-Client-Ip` from `CF-Connecting-IP`, the visitor Cloudflare saw.
|
|
151
|
+
clientAddress: 'vouched',
|
|
152
|
+
...(setup.options.operationsFetch === undefined ? {} : { operationsFetch: setup.options.operationsFetch }),
|
|
153
|
+
})
|
|
154
|
+
const booted: Booted = { app, migrates }
|
|
155
|
+
if (crashes !== null && !setup.watchingRejections) {
|
|
156
|
+
setup.watchingRejections = true
|
|
157
|
+
reportSurvivedCrashes(setup.options.rejections ?? globalRejectionEvents(), crashes)
|
|
158
|
+
}
|
|
159
|
+
return booted
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The isolate's booted application with this instance's storage migrated, or the first boot. Reports the first failure
|
|
164
|
+
* of a streak as `boot_failed`, whichever instance or step it comes from; an attachment that succeeds ends the streak.
|
|
165
|
+
*/
|
|
166
|
+
async function attach(
|
|
167
|
+
setup: ApplicationSetup | null,
|
|
168
|
+
state: ApplicationState,
|
|
169
|
+
env: Readonly<Record<string, unknown>>,
|
|
170
|
+
failed: (failure: OpsBootFailureContent) => void,
|
|
171
|
+
): Promise<Booted> {
|
|
172
|
+
if (setup === null) {
|
|
173
|
+
throw new Error('no contract application is registered: the generated entrypoint calls createContractWorker before an object starts')
|
|
174
|
+
}
|
|
175
|
+
const environment = captureEnvironment(Object.fromEntries(Object.entries(env).filter(([name]) => !FRAMEWORK_BINDINGS.has(name))))
|
|
176
|
+
const crashes = environmentCrashReporter(environment, setup.options.operationsFetch)
|
|
177
|
+
try {
|
|
178
|
+
const booted = await attachOnce(setup, state, env, environment, crashes)
|
|
179
|
+
setup.failureReported = false
|
|
180
|
+
return booted
|
|
181
|
+
} catch (error) {
|
|
182
|
+
failed(bootFailure(error, environmentScrubber(environment)))
|
|
183
|
+
if (crashes !== null && !setup.failureReported) {
|
|
184
|
+
setup.failureReported = true
|
|
185
|
+
state.waitUntil(crashes.report('boot_failed', error))
|
|
186
|
+
}
|
|
187
|
+
throw error
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function attachOnce(
|
|
192
|
+
setup: ApplicationSetup,
|
|
193
|
+
state: ApplicationState,
|
|
194
|
+
env: Readonly<Record<string, unknown>>,
|
|
195
|
+
environment: CapturedEnvironment,
|
|
196
|
+
crashes: CrashReporter | null,
|
|
197
|
+
): Promise<Booted> {
|
|
198
|
+
if (setup.booted === null) {
|
|
199
|
+
const booting = bootApplication(setup, state, env, environment, crashes)
|
|
200
|
+
setup.booted = booting
|
|
201
|
+
// A failed boot is not kept: the next object instance boots again and fails with the same problems.
|
|
202
|
+
booting.catch(() => {
|
|
203
|
+
setup.booted = null
|
|
204
|
+
})
|
|
205
|
+
return await booting
|
|
206
|
+
}
|
|
207
|
+
const booted = await setup.booted
|
|
208
|
+
if (booted.migrates) {
|
|
209
|
+
await migrate(state.storage, setup.options.migrations ?? [])
|
|
210
|
+
}
|
|
211
|
+
return booted
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A failed boot as the ops view shows it, its text scrubbed as a crash report's is. */
|
|
215
|
+
function bootFailure(error: unknown, scrubber: Scrubber): OpsBootFailureContent {
|
|
216
|
+
const at = new Date().toISOString()
|
|
217
|
+
if (error instanceof Error) {
|
|
218
|
+
return { at, message: scrubber.scrub(error.message), stack: typeof error.stack === 'string' ? scrubber.scrub(error.stack) : null }
|
|
219
|
+
}
|
|
220
|
+
const message = typeof error === 'string' ? scrubber.scrub(error) : `a thrown ${error === null ? 'null' : typeof error} that is not an Error`
|
|
221
|
+
return { at, message, stack: null }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
type Outcome = { readonly booted: Booted } | { readonly failure: unknown }
|
|
225
|
+
|
|
226
|
+
/** The wait after an instance's first failed boot before an event boots again; it doubles with each failure, to the cap. */
|
|
227
|
+
const BOOT_RETRY_MS = 1_000
|
|
228
|
+
const BOOT_RETRY_CAP_MS = 60_000
|
|
229
|
+
|
|
230
|
+
/** A job run handed to `waitUntil`; the runner logs and reports its own failures. */
|
|
231
|
+
async function runQuietly(app: ContractApp, name: string): Promise<void> {
|
|
232
|
+
try {
|
|
233
|
+
await app.jobs.run(name)
|
|
234
|
+
} catch {
|
|
235
|
+
console.error(`fabrika: job '${name}' did not start: driver_failed`)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The application environment's one object. The Worker's generated entrypoint exports it as `FabrikaApplication`. */
|
|
240
|
+
export class FabrikaApplication {
|
|
241
|
+
readonly #state: ApplicationState
|
|
242
|
+
readonly #env: Readonly<Record<string, unknown>>
|
|
243
|
+
readonly #setup: ApplicationSetup | null
|
|
244
|
+
/** `proxySecretDigest` of the environment's proxy secret; `null` refuses every request. */
|
|
245
|
+
readonly #secret: Uint8Array | null
|
|
246
|
+
/** What the platform wrote for Control's ops channel; `null` refuses every ops request. */
|
|
247
|
+
readonly #ops: OpsAccess | null
|
|
248
|
+
/** The instance's last failed boot, kept in memory for the ops view. */
|
|
249
|
+
#bootFailure: OpsBootFailureContent | null = null
|
|
250
|
+
/** The instance's boot, `null` until the first event that needs the application: an ops request never starts it. */
|
|
251
|
+
#outcome: Promise<Outcome> | null = null
|
|
252
|
+
#failedBoots = 0
|
|
253
|
+
/** When an event may boot again after a failed boot; until then every request answers the failure. */
|
|
254
|
+
#retryAt = 0
|
|
255
|
+
|
|
256
|
+
constructor(state: ApplicationState, env: Readonly<Record<string, unknown>>) {
|
|
257
|
+
this.#state = state
|
|
258
|
+
this.#env = env
|
|
259
|
+
this.#setup = registered
|
|
260
|
+
const secret = env[FABRIKA_PROXY_SECRET]
|
|
261
|
+
this.#secret = typeof secret === 'string' && secret !== '' ? proxySecretDigest(secret) : null
|
|
262
|
+
this.#ops = opsAccess(env)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async fetch(request: Request): Promise<Response> {
|
|
266
|
+
// Another Worker on the account that binds this class reaches nothing, not even an answer that the object exists.
|
|
267
|
+
if (!this.#fromWorker(request)) {
|
|
268
|
+
return new Response(null, { status: 404 })
|
|
269
|
+
}
|
|
270
|
+
if (new URL(request.url).pathname.startsWith(OPS_PREFIX)) {
|
|
271
|
+
// Served from storage alone: it never starts a boot or a retry, and waits for one under way only once authenticated.
|
|
272
|
+
return await serveOps(request, this.#ops, {
|
|
273
|
+
storage: this.#state.storage,
|
|
274
|
+
bootFailure: async () => {
|
|
275
|
+
await this.#outcome
|
|
276
|
+
return this.#bootFailure
|
|
277
|
+
},
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
const outcome = await this.#current()
|
|
281
|
+
if ('failure' in outcome) {
|
|
282
|
+
throw outcome.failure
|
|
283
|
+
}
|
|
284
|
+
return await objectStorage.run(this.#state.storage, () => this.#serve(outcome.booted, request))
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Settles either way: a callback that rejects would reset the object, and every request answers the failure instead. */
|
|
288
|
+
#boot(): Promise<Outcome> {
|
|
289
|
+
return this.#state.blockConcurrencyWhile(() =>
|
|
290
|
+
attach(this.#setup, this.#state, this.#env, (failure) => {
|
|
291
|
+
this.#bootFailure = failure
|
|
292
|
+
}).then((booted): Outcome => ({ booted }), (failure: unknown): Outcome => {
|
|
293
|
+
this.#failedBoots++
|
|
294
|
+
this.#retryAt = Date.now() + Math.min(BOOT_RETRY_MS * 2 ** (this.#failedBoots - 1), BOOT_RETRY_CAP_MS)
|
|
295
|
+
return { failure }
|
|
296
|
+
})
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The instance's boot, started by the first event that needs it and booted again once a failed boot's backoff has
|
|
302
|
+
* passed; concurrent events share one boot and one retry.
|
|
303
|
+
*/
|
|
304
|
+
async #current(): Promise<Outcome> {
|
|
305
|
+
if (this.#outcome === null) {
|
|
306
|
+
this.#outcome = this.#boot()
|
|
307
|
+
}
|
|
308
|
+
const pending = this.#outcome
|
|
309
|
+
const outcome = await pending
|
|
310
|
+
if (!('failure' in outcome) || Date.now() < this.#retryAt) {
|
|
311
|
+
return outcome
|
|
312
|
+
}
|
|
313
|
+
if (this.#outcome === pending) {
|
|
314
|
+
this.#outcome = this.#boot()
|
|
315
|
+
}
|
|
316
|
+
return await this.#outcome
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#serve(booted: Booted, request: Request): Promise<Response> {
|
|
320
|
+
const tick = request.headers.get(TICK_HEADER)
|
|
321
|
+
if (tick !== null) {
|
|
322
|
+
return Promise.resolve(this.#tick(booted, tick))
|
|
323
|
+
}
|
|
324
|
+
const exec: RequestExecutionContext = { waitUntil: (promise) => this.#state.waitUntil(promise) }
|
|
325
|
+
return booted.app.fetch(request, exec)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The fixed-size digests are compared in constant time, so neither the length nor a prefix of the secret leaks. */
|
|
329
|
+
#fromWorker(request: Request): boolean {
|
|
330
|
+
const presented = request.headers.get(PROXY_SECRET_HEADER) ?? ''
|
|
331
|
+
return this.#secret !== null && presented !== '' && timingSafeEqual(proxySecretDigest(presented), this.#secret)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Runs every job due in the tick's UTC minute and the jobs' check, each past the answer. */
|
|
335
|
+
#tick(booted: Booted, scheduledTime: string): Response {
|
|
336
|
+
const time = Number(scheduledTime)
|
|
337
|
+
if (!Number.isSafeInteger(time)) {
|
|
338
|
+
return new Response(null, { status: 400 })
|
|
339
|
+
}
|
|
340
|
+
const at = new Date(time)
|
|
341
|
+
const jobs = booted.app.jobs.scheduled
|
|
342
|
+
for (const job of jobs) {
|
|
343
|
+
if (isDueAt(job.schedule, at)) {
|
|
344
|
+
this.#state.waitUntil(runQuietly(booted.app, job.name))
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (jobs.length > 0) {
|
|
348
|
+
this.#state.waitUntil(booted.app.jobs.check())
|
|
349
|
+
}
|
|
350
|
+
return new Response(null, { status: 204 })
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// The Worker bindings a contract application's object boots with, each read structurally: IAM's application
|
|
2
|
+
// entrypoint, the SPA's Workers Assets and the owners' proxy Workers.
|
|
3
|
+
|
|
4
|
+
import { DecodedIamMembership, DecodedIamTerminals } from '@fabrika/auth'
|
|
5
|
+
import type { IamMembershipRpc, IamTerminalRpc, MintFromKeyResult } from '@fabrika/auth-core'
|
|
6
|
+
import { parseJwks } from '@fabrika/jwks'
|
|
7
|
+
import { z } from 'zod/v4'
|
|
8
|
+
import type { IamKeySource } from '../implement/boot.js'
|
|
9
|
+
import { CONNECT_BINDING_PREFIX, type ConnectFetch } from '../implement/connect.js'
|
|
10
|
+
import type { SpaAssets } from '../implement/dispatch.js'
|
|
11
|
+
import type { WorkloadTokenSource } from '../implement/workload.js'
|
|
12
|
+
import { isPageRoute } from '../spa-fallback.js'
|
|
13
|
+
|
|
14
|
+
/** The method `name` of a binding, bound to it, or `null` when it has none. */
|
|
15
|
+
export function method(target: unknown, name: string): ((...args: unknown[]) => unknown) | null {
|
|
16
|
+
if (typeof target !== 'object' || target === null || !(name in target)) {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
const found: unknown = Reflect.get(target, name)
|
|
20
|
+
return typeof found === 'function' ? (...args) => Reflect.apply(found, target, args) : null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function bindingKeySource(binding: unknown): IamKeySource {
|
|
24
|
+
const getJwks = method(binding, 'getJwks')
|
|
25
|
+
if (getJwks === null) {
|
|
26
|
+
throw new Error('the IAM binding is missing (env.IAM); the platform binds it to every application Worker')
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
async getJwks() {
|
|
30
|
+
const jwks = parseJwks(await getJwks())
|
|
31
|
+
if (jwks === null) {
|
|
32
|
+
throw new Error('IAM returned no usable key set')
|
|
33
|
+
}
|
|
34
|
+
return jwks
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* IAM's membership operations on the `IAM` binding (`PropustkaApplication`), every answer decoded like the HTTP
|
|
41
|
+
* transport's. A binding without them is no membership transport, and `boot` refuses a contract that needs one.
|
|
42
|
+
*/
|
|
43
|
+
export function bindingMembership(binding: unknown): IamMembershipRpc | undefined {
|
|
44
|
+
const methods = ['requestDelegation', 'createScope', 'assignMember', 'revokeMember', 'listMembers', 'issueLink', 'revokeLink'] as const
|
|
45
|
+
if (!methods.every((name) => method(binding, name) !== null)) {
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
return new DecodedIamMembership(async (name, input) => {
|
|
49
|
+
const call = method(binding, name)
|
|
50
|
+
if (call === null) {
|
|
51
|
+
throw new Error(`the IAM binding has no ${name}`)
|
|
52
|
+
}
|
|
53
|
+
return await call(input)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The identifications at a terminal over the IAM binding, when its entrypoint serves them (ADR-0067). */
|
|
58
|
+
export function bindingTerminals(binding: unknown): IamTerminalRpc | undefined {
|
|
59
|
+
const methods = ['inspectIdentification', 'assertIdentity', 'cancelIdentification'] as const
|
|
60
|
+
if (!methods.every((name) => method(binding, name) !== null)) {
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
return new DecodedIamTerminals(async (name, input) => {
|
|
64
|
+
const call = method(binding, name)
|
|
65
|
+
if (call === null) {
|
|
66
|
+
throw new Error(`the IAM binding has no ${name}`)
|
|
67
|
+
}
|
|
68
|
+
return await call(input)
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const mintFromKeyResultSchema = z.union([
|
|
73
|
+
z.strictObject({ ok: z.literal(true), token: z.string().min(1), expiresAt: z.number() }),
|
|
74
|
+
z.strictObject({ ok: z.literal(false), reason: z.enum(['invalid_key', 'unknown_principal', 'disabled', 'token_too_large']) }),
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* IAM's token exchange on the `IAM` binding (`mintFromKey`), for the keys of the jobs, webhooks and public procedures.
|
|
79
|
+
* An answer that is not IAM's decided outcome throws: "IAM could not be asked".
|
|
80
|
+
*/
|
|
81
|
+
export function bindingWorkloadTokens(binding: unknown): WorkloadTokenSource | undefined {
|
|
82
|
+
if (method(binding, 'mintFromKey') === null) {
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
async exchange(input): Promise<MintFromKeyResult> {
|
|
87
|
+
const mint = method(binding, 'mintFromKey')
|
|
88
|
+
if (mint === null) {
|
|
89
|
+
throw new Error('the IAM binding has no mintFromKey')
|
|
90
|
+
}
|
|
91
|
+
const parsed = mintFromKeyResultSchema.safeParse(await mint({ app: input.app, key: input.key, requestId: crypto.randomUUID() }))
|
|
92
|
+
if (!parsed.success) {
|
|
93
|
+
throw new Error('IAM answered the workload token exchange with something that is not its outcome')
|
|
94
|
+
}
|
|
95
|
+
return parsed.data
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The SPA and link mounts' files from the Workers Assets binding, whose directory mirrors the request paths: the mount
|
|
102
|
+
* `/admin` reads `admin/…` of it, the root mount the rest (the Cloudflare deploy stages each mount's `dir` there). A page
|
|
103
|
+
* route that names no file (`isPageRoute`) gets the mount's `index.html`, as `diskAssets` serves it: Workers Assets
|
|
104
|
+
* answers `<prefix>/` with it. A missing asset stays 404.
|
|
105
|
+
*/
|
|
106
|
+
export function bindingAssets(binding: unknown): SpaAssets | undefined {
|
|
107
|
+
const fetchAsset = method(binding, 'fetch')
|
|
108
|
+
if (fetchAsset === null) {
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
const fetched = async (request: Request): Promise<Response> => {
|
|
112
|
+
const response: unknown = await fetchAsset(request)
|
|
113
|
+
if (!(response instanceof Response)) {
|
|
114
|
+
throw new Error('the ASSETS binding answered something that is not a Response')
|
|
115
|
+
}
|
|
116
|
+
return response
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
async serve(mount, request) {
|
|
120
|
+
const response = await fetched(request)
|
|
121
|
+
if (response.status !== 404 || !isPageRoute(request)) {
|
|
122
|
+
return response
|
|
123
|
+
}
|
|
124
|
+
const index = new URL(request.url)
|
|
125
|
+
index.pathname = mount.prefix === '/' ? '/' : `${mount.prefix}/`
|
|
126
|
+
index.search = ''
|
|
127
|
+
if (index.pathname === new URL(request.url).pathname) {
|
|
128
|
+
return response
|
|
129
|
+
}
|
|
130
|
+
await response.body?.cancel()
|
|
131
|
+
return await fetched(new Request(index, { method: request.method, headers: request.headers }))
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The service bindings to owners' proxy Workers (`FABRIKA_CONNECT_<IMPORT>`), which `captureEnvironment` keeps from
|
|
138
|
+
* the application because no `FABRIKA_*` value reaches it. A binding routes by itself: the URL it is called with
|
|
139
|
+
* carries the owner's host, which the proxy Worker selects the application by.
|
|
140
|
+
*/
|
|
141
|
+
export function connectBindings(env: Readonly<Record<string, unknown>>): Record<string, ConnectFetch> {
|
|
142
|
+
const bindings: Record<string, ConnectFetch> = {}
|
|
143
|
+
for (const [name, binding] of Object.entries(env)) {
|
|
144
|
+
// Read, not tested with `in`: a binding stub may answer `fetch` without owning the property (Lopata's does not).
|
|
145
|
+
const fetchOwner: unknown = name.startsWith(CONNECT_BINDING_PREFIX) && typeof binding === 'object' && binding !== null
|
|
146
|
+
? Reflect.get(binding, 'fetch')
|
|
147
|
+
: undefined
|
|
148
|
+
if (typeof fetchOwner === 'function') {
|
|
149
|
+
bindings[name] = async (request) => {
|
|
150
|
+
const response: unknown = await Reflect.apply(fetchOwner, binding, [request])
|
|
151
|
+
if (!(response instanceof Response)) {
|
|
152
|
+
throw new Error(`the ${name} binding answered something that is not a Response`)
|
|
153
|
+
}
|
|
154
|
+
return response
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return bindings
|
|
159
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The names the Cloudflare provider and the application's Worker share (ADR-0065); `@fabrika/app/build` exports them.
|
|
2
|
+
|
|
3
|
+
/** The Durable Object class the Cloudflare entry exports: one object per application environment runs it (ADR-0065). */
|
|
4
|
+
export const CLOUDFLARE_APPLICATION_CLASS = 'FabrikaApplication'
|
|
5
|
+
|
|
6
|
+
/** The binding through which the application's Worker reaches its object. */
|
|
7
|
+
export const CLOUDFLARE_APPLICATION_BINDING = 'FABRIKA_APPLICATION'
|
|
8
|
+
|
|
9
|
+
/** The R2 bucket binding of the environment's stored files. */
|
|
10
|
+
export const CLOUDFLARE_FILES_BINDING = 'FABRIKA_FILES'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The Workers Assets binding the SPA and link mounts serve their files from. Its directory mirrors the request paths:
|
|
14
|
+
* the mount `/admin` serves `<directory>/admin/…`, the root mount `<directory>/…`.
|
|
15
|
+
*/
|
|
16
|
+
export const CLOUDFLARE_ASSETS_BINDING = 'ASSETS'
|