@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,72 @@
|
|
|
1
|
+
// The launcher's view of output: every line of the application and of the commands it runs is forwarded to its own
|
|
2
|
+
// stream, as Zerops' runtime log reads it, and the newest lines are kept to report. Secret values never reach either.
|
|
3
|
+
|
|
4
|
+
import { DEV_REPORT_MAX_LINE_CHARACTERS, type DevLogLine } from './wire.js'
|
|
5
|
+
|
|
6
|
+
export type OutputStream = DevLogLine['stream']
|
|
7
|
+
|
|
8
|
+
export interface OutputSink {
|
|
9
|
+
write(stream: OutputStream, line: string): void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The launcher process's own stdout and stderr. */
|
|
13
|
+
export const processOutput: OutputSink = {
|
|
14
|
+
write(stream, line) {
|
|
15
|
+
;(stream === 'stdout' ? process.stdout : process.stderr).write(`${line}\n`)
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The newest `capacity` lines, numbered from 0 since the launcher started, with every known secret value replaced. A
|
|
21
|
+
* mark starts the lines a report carries: those of the newest sync or reset, and the output after it.
|
|
22
|
+
*/
|
|
23
|
+
export class OutputRing {
|
|
24
|
+
private readonly lines: DevLogLine[] = []
|
|
25
|
+
private next = 0
|
|
26
|
+
private marked = 0
|
|
27
|
+
|
|
28
|
+
constructor(private readonly capacity: number, private readonly secrets: readonly string[], private readonly sink: OutputSink) {}
|
|
29
|
+
|
|
30
|
+
push(stream: OutputStream, text: string): void {
|
|
31
|
+
const line = this.redact(text)
|
|
32
|
+
this.sink.write(stream, line)
|
|
33
|
+
this.lines.push({ n: this.next, stream, text: line.slice(0, DEV_REPORT_MAX_LINE_CHARACTERS) })
|
|
34
|
+
this.next += 1
|
|
35
|
+
if (this.lines.length > this.capacity) this.lines.shift()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Report from the next line on: the lines before it belong to an older head. */
|
|
39
|
+
mark(): void {
|
|
40
|
+
this.marked = this.next
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The newest `count` lines since the mark, oldest first. */
|
|
44
|
+
tail(count: number): DevLogLine[] {
|
|
45
|
+
const since = this.lines.filter((line) => line.n >= this.marked)
|
|
46
|
+
return since.slice(Math.max(0, since.length - count))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private redact(text: string): string {
|
|
50
|
+
let line = text
|
|
51
|
+
for (const secret of this.secrets) line = line.replaceAll(secret, '[redacted]')
|
|
52
|
+
return line
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Feed `stream` to `onLine` one line at a time; a last line without a newline is fed when the stream ends. */
|
|
57
|
+
export async function readLines(stream: ReadableStream<Uint8Array>, onLine: (line: string) => void): Promise<void> {
|
|
58
|
+
const decoder = new TextDecoder()
|
|
59
|
+
const reader = stream.getReader()
|
|
60
|
+
let pending = ''
|
|
61
|
+
for (let read = await reader.read(); !read.done; read = await reader.read()) {
|
|
62
|
+
pending += decoder.decode(read.value, { stream: true })
|
|
63
|
+
let end = pending.indexOf('\n')
|
|
64
|
+
while (end !== -1) {
|
|
65
|
+
onLine(pending.slice(0, end).replace(/\r$/, ''))
|
|
66
|
+
pending = pending.slice(end + 1)
|
|
67
|
+
end = pending.indexOf('\n')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
pending += decoder.decode()
|
|
71
|
+
if (pending !== '') onLine(pending)
|
|
72
|
+
}
|
package/src/dev/wire.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The wire of the platform transport's source receiver (ADR-0064): what the launcher of a `dev` environment asks
|
|
2
|
+
// Control and what it reports back. Both sides import it, so the shapes and the one parser of each message cannot drift
|
|
3
|
+
// apart. Runtime-neutral: zod and Web APIs only.
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod/v4'
|
|
6
|
+
|
|
7
|
+
/** The long poll under the receiver's URL (`FABRIKA_PLATFORM_SOURCE_URL`). */
|
|
8
|
+
export const DEV_SOURCE_WAIT_PATH = '/platform/source/wait'
|
|
9
|
+
/** Where the launcher reports what it serves. */
|
|
10
|
+
export const DEV_SOURCE_REPORT_PATH = '/platform/source/report'
|
|
11
|
+
|
|
12
|
+
/** How long Control holds a wait before it answers `idle`: below every proxy's idle timeout on the way. */
|
|
13
|
+
export const DEV_SOURCE_WAIT_MS = 25_000
|
|
14
|
+
|
|
15
|
+
/** The most log lines one report carries, and the longest line. */
|
|
16
|
+
export const DEV_REPORT_MAX_LINES = 200
|
|
17
|
+
export const DEV_REPORT_MAX_LINE_CHARACTERS = 2_000
|
|
18
|
+
export const DEV_REPORT_MAX_MESSAGE_CHARACTERS = 500
|
|
19
|
+
|
|
20
|
+
const commitSha = z.string().regex(/^[0-9a-f]{40}$/)
|
|
21
|
+
const generation = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER)
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Repository-relative with forward slashes: no leading slash, no backslash, no empty, `.` or `..` segment, no control
|
|
25
|
+
* character. The launcher writes these paths under its root, so nothing it receives may name a path outside it.
|
|
26
|
+
*/
|
|
27
|
+
export function isSourcePath(path: string): boolean {
|
|
28
|
+
return path.length > 0 && path.length <= 512 && !path.startsWith('/') && !path.includes('\\')
|
|
29
|
+
&& ![...path].some((character) => character.charCodeAt(0) <= 0x1f || character.charCodeAt(0) === 0x7f)
|
|
30
|
+
&& path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sourcePath = z.string().refine(isSourcePath, 'not a repository-relative path')
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The commit the container's bootstrap run built (`DevLauncherConfig.head`). Control answers only the container of the
|
|
37
|
+
* newest bootstrap run: a container a newer bootstrap replaces still runs for a while, and must not sync or report.
|
|
38
|
+
*/
|
|
39
|
+
const bootstrap = commitSha
|
|
40
|
+
|
|
41
|
+
export const devWaitRequestSchema = z.object({
|
|
42
|
+
bootstrap,
|
|
43
|
+
/** The commit whose files the container holds now. */
|
|
44
|
+
head: commitSha,
|
|
45
|
+
/** The reset generation it applied last; null for a container with no record of one, which adopts the answer's. */
|
|
46
|
+
reset: generation.nullable(),
|
|
47
|
+
/** The check request it ran last; null as for `reset`. */
|
|
48
|
+
checks: generation.nullable(),
|
|
49
|
+
/** False at boot: answer at once, so the launcher reconciles before it serves. */
|
|
50
|
+
hold: z.boolean(),
|
|
51
|
+
}).strict()
|
|
52
|
+
|
|
53
|
+
export type DevWaitRequest = z.infer<typeof devWaitRequestSchema>
|
|
54
|
+
|
|
55
|
+
/** One changed file at the target commit; `content` null deletes it. */
|
|
56
|
+
export const devSourceFileSchema = z.object({ path: sourcePath, content: z.string().nullable() }).strict()
|
|
57
|
+
|
|
58
|
+
export type DevSourceFile = z.infer<typeof devSourceFileSchema>
|
|
59
|
+
|
|
60
|
+
/** The files that bring the container from `from` to `to`. */
|
|
61
|
+
export const devSyncSchema = z.object({ from: commitSha, to: commitSha, files: z.array(devSourceFileSchema) }).strict()
|
|
62
|
+
|
|
63
|
+
export type DevSync = z.infer<typeof devSyncSchema>
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What to do. `sync` is null when the container holds the recorded head (or a head it refused). `reset` and `checks` are
|
|
67
|
+
* the environment's current generations: one past what the container applied asks it to reset its schema, or to run
|
|
68
|
+
* the check chain.
|
|
69
|
+
*/
|
|
70
|
+
export const devWaitAnswerSchema = z.object({ sync: devSyncSchema.nullable(), reset: generation, checks: generation }).strict()
|
|
71
|
+
|
|
72
|
+
export type DevWaitAnswer = z.infer<typeof devWaitAnswerSchema>
|
|
73
|
+
|
|
74
|
+
export const DEV_STATUSES = ['syncing', 'serving', 'failed', 'needs_bootstrap', 'resetting', 'checking'] as const
|
|
75
|
+
|
|
76
|
+
export type DevStatus = (typeof DEV_STATUSES)[number]
|
|
77
|
+
|
|
78
|
+
export const devLogLineSchema = z.object({
|
|
79
|
+
/** The line's number in the launcher's output since it started; it only grows. */
|
|
80
|
+
n: z.number().int().min(0),
|
|
81
|
+
stream: z.enum(['stdout', 'stderr']),
|
|
82
|
+
text: z.string().max(DEV_REPORT_MAX_LINE_CHARACTERS),
|
|
83
|
+
}).strict()
|
|
84
|
+
|
|
85
|
+
export type DevLogLine = z.infer<typeof devLogLineSchema>
|
|
86
|
+
|
|
87
|
+
export const devCheckResultSchema = z.object({
|
|
88
|
+
name: z.string().min(1).max(64),
|
|
89
|
+
ok: z.boolean(),
|
|
90
|
+
exitCode: z.number().int().nullable(),
|
|
91
|
+
durationMs: z.number().int().min(0),
|
|
92
|
+
/** The last lines of its output. */
|
|
93
|
+
output: z.array(z.string().max(DEV_REPORT_MAX_LINE_CHARACTERS)).max(DEV_REPORT_MAX_LINES),
|
|
94
|
+
}).strict()
|
|
95
|
+
|
|
96
|
+
export type DevCheckResult = z.infer<typeof devCheckResultSchema>
|
|
97
|
+
|
|
98
|
+
export const devReportSchema = z.object({
|
|
99
|
+
bootstrap,
|
|
100
|
+
/** The commit whose files the container holds now. */
|
|
101
|
+
head: commitSha,
|
|
102
|
+
status: z.enum(DEV_STATUSES),
|
|
103
|
+
/** The commit the launcher is syncing to or refused; null when it has none. */
|
|
104
|
+
target: commitSha.nullable(),
|
|
105
|
+
/** One line: why a sync was refused or failed. */
|
|
106
|
+
message: z.string().max(DEV_REPORT_MAX_MESSAGE_CHARACTERS).nullable(),
|
|
107
|
+
/** The newest lines of the application's and the launcher's output, oldest first. */
|
|
108
|
+
log: z.array(devLogLineSchema).max(DEV_REPORT_MAX_LINES),
|
|
109
|
+
reset: generation.nullable(),
|
|
110
|
+
/** The newest check run: the request it answers, the commit whose files it checked, and each check's result. */
|
|
111
|
+
checks: z.object({ request: generation, head: commitSha, results: z.array(devCheckResultSchema).max(8) }).strict().nullable(),
|
|
112
|
+
}).strict()
|
|
113
|
+
|
|
114
|
+
export type DevReport = z.infer<typeof devReportSchema>
|
|
115
|
+
|
|
116
|
+
/** The body of a wait as JSON text. */
|
|
117
|
+
export function encodeDevWaitRequest(request: DevWaitRequest): string {
|
|
118
|
+
return JSON.stringify(devWaitRequestSchema.parse(request))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The body of a report as JSON text. */
|
|
122
|
+
export function encodeDevReport(report: DevReport): string {
|
|
123
|
+
return JSON.stringify(devReportSchema.parse(report))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type DevParse<T> = { readonly ok: true; readonly value: T } | { readonly ok: false }
|
|
127
|
+
|
|
128
|
+
function parseWith<T>(schema: z.ZodType<T>, json: unknown): DevParse<T> {
|
|
129
|
+
const parsed = schema.safeParse(json)
|
|
130
|
+
return parsed.success ? { ok: true, value: parsed.data } : { ok: false }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The one parser of a wait request; a field this release does not know is refused, never dropped. */
|
|
134
|
+
export const parseDevWaitRequest = (json: unknown): DevParse<DevWaitRequest> => parseWith(devWaitRequestSchema, json)
|
|
135
|
+
export const parseDevWaitAnswer = (json: unknown): DevParse<DevWaitAnswer> => parseWith(devWaitAnswerSchema, json)
|
|
136
|
+
export const parseDevReport = (json: unknown): DevParse<DevReport> => parseWith(devReportSchema, json)
|
package/src/disk-assets.ts
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
import { rawRequestTarget } from '@fabrika/auth-core'
|
|
5
5
|
import { join, resolve, sep } from 'node:path'
|
|
6
6
|
import type { SpaAssets } from './implement/dispatch.js'
|
|
7
|
+
import { isPageRoute } from './spa-fallback.js'
|
|
7
8
|
|
|
8
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Serves each SPA mount from its build directory under `root`; a page route that names no file gets the SPA's
|
|
11
|
+
* `index.html` (`isPageRoute`), and a missing asset is 404.
|
|
12
|
+
*/
|
|
9
13
|
export function diskAssets(root: string): SpaAssets {
|
|
10
14
|
return {
|
|
11
15
|
async serve(mount, request) {
|
|
@@ -15,8 +19,9 @@ export function diskAssets(root: string): SpaAssets {
|
|
|
15
19
|
// The path is canonical, so every segment decodes and none is `.` or `..`.
|
|
16
20
|
const file = join(dir, ...relative.split('/').filter((segment) => segment !== '').map((segment) => decodeURIComponent(segment)))
|
|
17
21
|
const candidate = file.startsWith(`${dir}${sep}`) ? Bun.file(file) : null
|
|
18
|
-
const
|
|
19
|
-
|
|
22
|
+
const found = candidate !== null && (await candidate.exists()) ? candidate : null
|
|
23
|
+
const served = found ?? (isPageRoute(request) ? Bun.file(join(dir, 'index.html')) : null)
|
|
24
|
+
if (served === null || !(await served.exists())) {
|
|
20
25
|
return new Response('Not found', { status: 404 })
|
|
21
26
|
}
|
|
22
27
|
return new Response(served)
|
package/src/files/archive.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// stored or deflated entries, no encryption, a layout in which every byte belongs to an entry or the directory, sizes
|
|
5
5
|
// and CRCs that match, bounded counts and sizes, and nested ZIPs to a small depth. Any other archive is refused.
|
|
6
6
|
|
|
7
|
-
import { inflateRawSync } from 'node:zlib'
|
|
7
|
+
import { crc32, inflateRawSync } from 'node:zlib'
|
|
8
8
|
|
|
9
9
|
export const ARCHIVE_LIMITS = {
|
|
10
10
|
/** Entries of one archive, nested ones counted with their parent's. */
|
|
@@ -236,7 +236,7 @@ function unpacked(entry: Entry, data: Uint8Array, budget: Budget): Uint8Array {
|
|
|
236
236
|
throw new Refused('malformed')
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
|
-
if (content.length !== entry.uncompressed ||
|
|
239
|
+
if (content.length !== entry.uncompressed || crc32(content) !== entry.crc) throw new Refused('malformed')
|
|
240
240
|
return content
|
|
241
241
|
}
|
|
242
242
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The framework's own file jobs, in a module of their own: the build recipe schedules them without the file stack.
|
|
2
|
+
|
|
3
|
+
/** The reserved name of the scan job: a `c.cron` job name cannot contain `-`, so no application job takes it. */
|
|
4
|
+
export const FILE_SCAN_JOB = 'fabrika-file-scan'
|
|
5
|
+
export const FILE_SCAN_SCHEDULE = '* * * * *'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The framework's sweep job: hourly, it deletes the files no declared column references and the pending ones past the
|
|
9
|
+
* grace, so none waits for the next request. A reserved name with `-`, which no `c.cron` name can take.
|
|
10
|
+
*/
|
|
11
|
+
export const FILE_SWEEP_JOB = 'fabrika-file-sweep'
|
|
12
|
+
export const FILE_SWEEP_SCHEDULE = '41 * * * *'
|
package/src/files/kinds.ts
CHANGED
|
@@ -29,13 +29,7 @@ import { bytesAreOfType, SNIFF_BYTES, SNIFFABLE_TYPES } from './sniff'
|
|
|
29
29
|
import { cleanName, digesting, isNamed, readExactly, readHead } from './streams'
|
|
30
30
|
import { FileDownload } from './treatment'
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
* The framework's sweep job: hourly, it deletes the files no declared column references and the pending ones past the
|
|
34
|
-
* grace, so none waits for the next request. A reserved name with `-`, which no `c.cron` name can take;
|
|
35
|
-
* `@fabrika/provider-zerops` writes it into the crontab on this schedule (`ZEROPS_FILE_SWEEP_JOB`).
|
|
36
|
-
*/
|
|
37
|
-
export const FILE_SWEEP_JOB = 'fabrika-file-sweep'
|
|
38
|
-
export const FILE_SWEEP_SCHEDULE = '41 * * * *'
|
|
32
|
+
export { FILE_SWEEP_JOB, FILE_SWEEP_SCHEDULE } from './job-names'
|
|
39
33
|
export const FILE_SWEEP_TIMEOUT_MS = 10 * 60_000
|
|
40
34
|
/** How long a staged file waits for its first reference before the sweep may delete it. */
|
|
41
35
|
export const FILE_PENDING_GRACE_MS = 60 * 60 * 1000
|
|
@@ -67,6 +61,7 @@ export class FileWork {
|
|
|
67
61
|
readonly holder: object
|
|
68
62
|
readonly request: string
|
|
69
63
|
readonly #staged: string[] = []
|
|
64
|
+
readonly #written = new Map<string, string>()
|
|
70
65
|
|
|
71
66
|
constructor(holder: object, request: string) {
|
|
72
67
|
this.holder = holder
|
|
@@ -77,9 +72,18 @@ export class FileWork {
|
|
|
77
72
|
this.#staged.push(id)
|
|
78
73
|
}
|
|
79
74
|
|
|
75
|
+
/** The work wrote the object of a file it staged, under a key no other file has. */
|
|
76
|
+
wrote(id: string, objectKey: string): void {
|
|
77
|
+
this.#written.set(id, objectKey)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
80
|
get stagedIds(): readonly string[] {
|
|
81
81
|
return [...this.#staged]
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
writtenKey(id: string): string | null {
|
|
85
|
+
return this.#written.get(id) ?? null
|
|
86
|
+
}
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
interface Kind {
|
|
@@ -188,6 +192,7 @@ export class StoredFileKinds {
|
|
|
188
192
|
const body = digesting(stream, null)
|
|
189
193
|
try {
|
|
190
194
|
const { size } = await this.#store.write(objectKey, body.stream, { contentType: type, maxBytes: artifact.maxBytes })
|
|
195
|
+
work.wrote(id, objectKey)
|
|
191
196
|
const sha256 = body.digest()
|
|
192
197
|
await this.#files.written(id, { size, sha256 })
|
|
193
198
|
return fileReference({ id, kind: kind.name, name, type, size, status: 'pending' }, work.holder, true)
|
|
@@ -212,6 +217,13 @@ export class StoredFileKinds {
|
|
|
212
217
|
try {
|
|
213
218
|
const objectKey = await this.#files.abandon(id, work.request)
|
|
214
219
|
if (objectKey === null) {
|
|
220
|
+
// On SQLite the pending row is written in the work's own transaction and rolled back with it, so only the
|
|
221
|
+
// work knows the object; a row still there is a file something claimed, whose object stays.
|
|
222
|
+
const written = work.writtenKey(id)
|
|
223
|
+
if (written !== null && (await this.#files.find(id)) === null) {
|
|
224
|
+
step = 'object_delete_failed'
|
|
225
|
+
await this.#store.delete(written)
|
|
226
|
+
}
|
|
215
227
|
return
|
|
216
228
|
}
|
|
217
229
|
step = 'object_delete_failed'
|
package/src/files/scan.ts
CHANGED
|
@@ -7,13 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
import type { QuarantinedStoredFile } from '@fabrika/db/framework'
|
|
9
9
|
import type { FileObjectStore } from '@fabrika/platform'
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
10
11
|
import type { OperationsReporter } from '../jobs/report'
|
|
11
12
|
import { type ArchiveRefusal, checkArchive } from './archive'
|
|
12
13
|
import { ZIP_TYPES } from './sniff'
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
export const FILE_SCAN_JOB = 'fabrika-file-scan'
|
|
16
|
-
export const FILE_SCAN_SCHEDULE = '* * * * *'
|
|
15
|
+
export { FILE_SCAN_JOB, FILE_SCAN_SCHEDULE } from './job-names'
|
|
17
16
|
|
|
18
17
|
export const SCAN_LIMITS = {
|
|
19
18
|
/** One pass scans at most this many files, and at most `batchBytes` of them; the next pass takes the rest. */
|
|
@@ -181,7 +180,7 @@ class ScanPass {
|
|
|
181
180
|
}
|
|
182
181
|
const bytes = object.size === file.size ? await readExactly(object.body, file.size, signal) : null
|
|
183
182
|
if (object.size !== file.size) await object.body.cancel()
|
|
184
|
-
if (bytes === null ||
|
|
183
|
+
if (bytes === null || createHash('sha256').update(bytes).digest('hex') !== file.sha256) {
|
|
185
184
|
if (!signal.aborted) this.#states.set(file.id, { kind: 'mismatch' })
|
|
186
185
|
throw new Error('the object differs from its upload')
|
|
187
186
|
}
|
package/src/files/streams.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// The byte streams of a stored file on its way in and out: the head the sniff reads, the digest and the deadline of a
|
|
2
2
|
// write, the exact read of a file a site opens, and the client's file name as metadata.
|
|
3
3
|
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
|
|
4
6
|
const MAX_NAME_CODE_POINTS = 255
|
|
5
7
|
|
|
6
8
|
/** The client's file name, metadata only: at most 255 code points, never half a character, no control characters. */
|
|
@@ -62,7 +64,7 @@ export function digesting(body: ReadableStream<Uint8Array>, timeoutMs: number |
|
|
|
62
64
|
timedOut(): boolean
|
|
63
65
|
stop(): void
|
|
64
66
|
} {
|
|
65
|
-
const hasher =
|
|
67
|
+
const hasher = createHash('sha256')
|
|
66
68
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
67
69
|
let expired = false
|
|
68
70
|
const stream = body.pipeThrough(
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { LINK_BOOTSTRAP_FILE, SHARE_KEY_GLOBAL } from '../contract/defaults'
|
|
6
6
|
import { HttpError } from '../errors'
|
|
7
|
-
import { readBoundedBytes } from './bounded'
|
|
7
|
+
import { deadline, readBoundedBytes } from './bounded'
|
|
8
8
|
|
|
9
9
|
/** The slowest body a procedure waits for, in bytes per second: its deadline is `maxBytes` at this rate, 30 s at least. */
|
|
10
10
|
export const BODY_RATE_FLOOR = 16 * 1024
|
|
@@ -36,6 +36,16 @@ export async function readBoundedBody(request: Request, maxBytes: number, deadli
|
|
|
36
36
|
return body
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** `readBoundedBody` within the deadline `maxBytes` derives; the deadline ends with the read. */
|
|
40
|
+
export async function readBodyInTime(request: Request, maxBytes: number): Promise<Uint8Array<ArrayBuffer>> {
|
|
41
|
+
const timer = deadline(bodyDeadlineMs(maxBytes))
|
|
42
|
+
try {
|
|
43
|
+
return await readBoundedBody(request, maxBytes, timer.signal)
|
|
44
|
+
} finally {
|
|
45
|
+
timer.clear()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
/** What a request answers whose body did not arrive whole: nobody reads it, and no failure is reported. */
|
|
40
50
|
export function abortedBody(): HttpError {
|
|
41
51
|
return new HttpError(400, 'aborted', 'The body could not be read to its end')
|
package/src/implement/boot.ts
CHANGED
|
@@ -17,13 +17,21 @@ import {
|
|
|
17
17
|
FABRIKA_IAM_APP_ID,
|
|
18
18
|
FABRIKA_PROXY_SECRET,
|
|
19
19
|
type IamMembershipRpc,
|
|
20
|
+
type IamTerminalRpc,
|
|
20
21
|
isLoopbackHost,
|
|
21
22
|
type Jwks,
|
|
22
23
|
parseAppEnvironmentId,
|
|
23
24
|
} from '@fabrika/auth-core'
|
|
24
25
|
import type { Database } from '@fabrika/db'
|
|
25
26
|
import type { CapabilityAuthority } from '@fabrika/db/capability'
|
|
26
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
bindScopedDatabaseProvisionally,
|
|
29
|
+
type DataAccess,
|
|
30
|
+
dataAccessOf,
|
|
31
|
+
type FrameworkTables,
|
|
32
|
+
frameworkTablesOf,
|
|
33
|
+
isScopedDatabase,
|
|
34
|
+
} from '@fabrika/db/framework'
|
|
27
35
|
import type { DataKind } from '@fabrika/db/tables'
|
|
28
36
|
import type { EmailProviderRegistry } from '@fabrika/email'
|
|
29
37
|
import { builtinEmailProviders } from '@fabrika/email/providers'
|
|
@@ -57,7 +65,7 @@ import {
|
|
|
57
65
|
type ServedSite,
|
|
58
66
|
type SpaAssets,
|
|
59
67
|
} from './dispatch'
|
|
60
|
-
import { installEgressGuard, platformCall, platformFacility } from './egress'
|
|
68
|
+
import { type HostResolver, installEgressGuard, platformCall, platformFacility, publicEgressRules, type SiteEgress, siteEgress } from './egress'
|
|
61
69
|
import type { CapturedEnvironment } from './environment'
|
|
62
70
|
import { FailureReports } from './failures'
|
|
63
71
|
import { applicationPolicy } from './headers'
|
|
@@ -101,6 +109,11 @@ export interface BootOptions {
|
|
|
101
109
|
* creates scopes, manages members or issues share links does not boot.
|
|
102
110
|
*/
|
|
103
111
|
readonly membership?: IamMembershipRpc
|
|
112
|
+
/**
|
|
113
|
+
* IAM's identification operations at a terminal (ADR-0067): the IAM binding on Cloudflare, `HttpIamTerminals`
|
|
114
|
+
* elsewhere. Without it, a contract that declares a terminal does not boot.
|
|
115
|
+
*/
|
|
116
|
+
readonly terminals?: IamTerminalRpc
|
|
104
117
|
/**
|
|
105
118
|
* Cloudflare service bindings to the owners' proxy Workers, by binding name (`FABRIKA_CONNECT_<IMPORT>`). An import
|
|
106
119
|
* without one calls its owner with `fetch`.
|
|
@@ -111,6 +124,11 @@ export interface BootOptions {
|
|
|
111
124
|
* with a job or a webhook does not boot.
|
|
112
125
|
*/
|
|
113
126
|
readonly workloadTokens?: WorkloadTokenSource
|
|
127
|
+
/**
|
|
128
|
+
* Resolves a host name to its addresses, which the Bun adapter supplies (`node:dns`): a cron job's public egress then
|
|
129
|
+
* refuses a public name of a private address before each hop. A Worker exposes no resolution and passes none.
|
|
130
|
+
*/
|
|
131
|
+
readonly resolveHost?: HostResolver
|
|
114
132
|
/** What posts a report to Operations' ingest; `fetch` by default. */
|
|
115
133
|
readonly operationsFetch?: (request: Request) => Promise<Response>
|
|
116
134
|
/** What asks a public procedure's challenge provider to verify a token; `fetch` by default. */
|
|
@@ -262,6 +280,23 @@ function readProxySecret(framework: Readonly<Record<string, string>>, required:
|
|
|
262
280
|
return proxySecretDigest(secret)
|
|
263
281
|
}
|
|
264
282
|
|
|
283
|
+
/** What an application's SQLite database (a Durable Object's, ADR-0065) cannot serve: raw SQL and a scanning file kind. */
|
|
284
|
+
function sqliteProblems(artifact: ContractArtifact, database: Database | undefined, problems: string[]): void {
|
|
285
|
+
if (database?.dialect !== 'sqlite') {
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
for (const [site, reach] of Object.entries(artifact.declared ?? {})) {
|
|
289
|
+
if (reach.sql !== null) {
|
|
290
|
+
problems.push(`'${site}' declares sql: '${reach.sql}'; ctx.sql runs only on Postgres, and this application's database is SQLite (Cloudflare)`)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const [name, kind] of Object.entries(artifact.fileKinds ?? {})) {
|
|
294
|
+
if (kind.scan === true) {
|
|
295
|
+
problems.push(`files '${name}' declares scan: true; no malware scanner runs on Cloudflare, so a scanning file kind is not served there`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
265
300
|
/** What this version cannot serve yet; a contract that declares it does not boot rather than answer nothing. */
|
|
266
301
|
function unservable(
|
|
267
302
|
artifact: ContractArtifact,
|
|
@@ -350,6 +385,8 @@ interface BoundDatabase {
|
|
|
350
385
|
readonly framework: FrameworkTables | null
|
|
351
386
|
/** The mini-ORM behind a staged-kit site's `ctx.db`; only a `scoped(tables)` layer has it. */
|
|
352
387
|
readonly data: DataAccess | null
|
|
388
|
+
/** Disconnects a `scoped(tables)` layer this boot bound; `null` for any other database. */
|
|
389
|
+
readonly release: (() => void) | null
|
|
353
390
|
}
|
|
354
391
|
|
|
355
392
|
/**
|
|
@@ -367,7 +404,7 @@ function contractDatabase(
|
|
|
367
404
|
}
|
|
368
405
|
if (!isScopedDatabase(database)) {
|
|
369
406
|
if ('locate' in database) {
|
|
370
|
-
return { database, framework: null, data: null }
|
|
407
|
+
return { database, framework: null, data: null, release: null }
|
|
371
408
|
}
|
|
372
409
|
problems.push('serve(contract, { database }) takes the scoped(tables) result of @fabrika/db itself, not a copy or a wrapper of it')
|
|
373
410
|
return null
|
|
@@ -377,8 +414,8 @@ function contractDatabase(
|
|
|
377
414
|
return null
|
|
378
415
|
}
|
|
379
416
|
try {
|
|
380
|
-
const locate =
|
|
381
|
-
return { database: { tables: database.tables, locate }, framework: frameworkTablesOf(database), data: dataAccessOf(database) }
|
|
417
|
+
const { locate, release } = bindScopedDatabaseProvisionally(database, { database: connection, capabilities, readOnly: requestIsReadOnly })
|
|
418
|
+
return { database: { tables: database.tables, locate }, framework: frameworkTablesOf(database), data: dataAccessOf(database), release }
|
|
382
419
|
} catch {
|
|
383
420
|
problems.push('the data layer was bound before boot; only the framework binds it, so remove @fabrika/db/framework from the application')
|
|
384
421
|
return null
|
|
@@ -428,8 +465,8 @@ function boundHandler(
|
|
|
428
465
|
}
|
|
429
466
|
|
|
430
467
|
/** The origins a staged-kit site's handler reaches, a kit without tables included; nothing for a bare handler. */
|
|
431
|
-
function egressOf(bound: BoundHandler): { readonly egress?:
|
|
432
|
-
return !bound.site || bound.declaration === null ? {} : { egress: bound.declaration.egress }
|
|
468
|
+
function egressOf(bound: BoundHandler): { readonly egress?: SiteEgress } {
|
|
469
|
+
return !bound.site || bound.declaration === null ? {} : { egress: siteEgress(bound.declaration.egress, null) }
|
|
433
470
|
}
|
|
434
471
|
|
|
435
472
|
/** A staged-kit contract's endpoint gets `ctx.links` only for the link mount it declares it `issues`. */
|
|
@@ -651,11 +688,28 @@ function runtimePurposes(contract: AnyContract, artifact: ContractArtifact): Rea
|
|
|
651
688
|
}
|
|
652
689
|
|
|
653
690
|
export function boot(app: ContractImplementation, options: BootOptions): ContractApp {
|
|
691
|
+
const binding: Binding = { release: null }
|
|
692
|
+
try {
|
|
693
|
+
return bootOnce(app, options, binding)
|
|
694
|
+
} catch (error) {
|
|
695
|
+
// A failed boot releases the data layer it bound, so a later boot of the same application binds it again.
|
|
696
|
+
binding.release?.()
|
|
697
|
+
throw error
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** What `bootOnce` bound: released when the boot fails, kept when it succeeds. */
|
|
702
|
+
interface Binding {
|
|
703
|
+
release: (() => void) | null
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function bootOnce(app: ContractImplementation, options: BootOptions, binding: Binding): ContractApp {
|
|
654
707
|
const problems: string[] = []
|
|
655
708
|
installEgressGuard()
|
|
656
709
|
// What a handler reaches through these is the platform's own request, which no site's egress covers.
|
|
657
710
|
const objects = options.files === undefined ? undefined : platformFacility(options.files)
|
|
658
711
|
const membershipRpc = options.membership === undefined ? undefined : platformFacility(options.membership)
|
|
712
|
+
const terminalRpc = options.terminals === undefined ? undefined : platformFacility(options.terminals)
|
|
659
713
|
const workloadTokens = options.workloadTokens === undefined ? undefined : platformFacility(options.workloadTokens)
|
|
660
714
|
const variables = readFramework(options.environment.framework, app.contract.vocabulary.app, problems)
|
|
661
715
|
const artifact = compileContract(app.contract, app.database?.tables ?? {})
|
|
@@ -673,10 +727,12 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
673
727
|
const owned = new Set(Object.values(artifact.tables).flatMap((table) => table.scope.mode === 'dimension' ? [table.scope.dimension] : []))
|
|
674
728
|
const containers = containersOf(artifact)
|
|
675
729
|
unservable(artifact, options.assets, owned, containers, problems)
|
|
730
|
+
sqliteProblems(artifact, options.database, problems)
|
|
676
731
|
// This runtime's own: what another `boot` call mints, application code's included, its stores refuse.
|
|
677
732
|
const issuer = createCapabilityIssuer()
|
|
678
733
|
const fileKinds = storedFileKinds(artifact, app.database, objects, problems)
|
|
679
734
|
const bound = contractDatabase(app.database, options.database, issuer.authority, problems)
|
|
735
|
+
binding.release = bound?.release ?? null
|
|
680
736
|
const framework = bound?.framework ?? null
|
|
681
737
|
frameworkProblems(artifact, framework, problems)
|
|
682
738
|
const database = bound?.database ?? null
|
|
@@ -848,6 +904,7 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
848
904
|
mail,
|
|
849
905
|
tokens,
|
|
850
906
|
url,
|
|
907
|
+
publicEgress: publicEgressRules(options.environment.framework, options.resolveHost ?? null),
|
|
851
908
|
}, problems)
|
|
852
909
|
for (const id of jobs.declared) {
|
|
853
910
|
declared.add(id)
|
|
@@ -896,6 +953,11 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
896
953
|
if (site !== undefined && data === null) {
|
|
897
954
|
problems.push(`'${site.row.id}' reaches data through ctx.db, which runs on @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
|
|
898
955
|
}
|
|
956
|
+
if (artifact.terminal !== undefined && options.terminals === undefined) {
|
|
957
|
+
problems.push(
|
|
958
|
+
'the contract declares a terminal, and the runtime adapter supplies no IAM transport for its identifications (the IAM binding on Cloudflare; FABRIKA_IAM_RPC_URL and FABRIKA_IAM_RPC_KEY on Bun)',
|
|
959
|
+
)
|
|
960
|
+
}
|
|
899
961
|
const asking = [...endpoints.values()].find(asksIam)
|
|
900
962
|
if (asking !== undefined && options.membership === undefined) {
|
|
901
963
|
problems.push(
|
|
@@ -919,6 +981,7 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
|
|
|
919
981
|
containers,
|
|
920
982
|
assets: options.assets ?? null,
|
|
921
983
|
membership,
|
|
984
|
+
terminals: artifact.terminal === undefined ? null : (terminalRpc ?? null),
|
|
922
985
|
framework,
|
|
923
986
|
mint: issuer.mint,
|
|
924
987
|
capabilities: issuer.authority,
|
package/src/implement/bounded.ts
CHANGED
|
@@ -8,9 +8,28 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export const REQUEST_BUDGET_MS = 10_000
|
|
10
10
|
|
|
11
|
+
/** A signal that aborts once its time passed, and a `clear` its work calls when it settles. */
|
|
12
|
+
export interface Deadline {
|
|
13
|
+
readonly signal: AbortSignal
|
|
14
|
+
clear(): void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Aborts like `AbortSignal.timeout(ms)`, or when `parent` aborts. Its timer is cleared by `clear`, which every caller
|
|
19
|
+
* runs in `finally`: workerd counts a pending timer as work, so an uncleared one keeps a Durable Object awake.
|
|
20
|
+
*/
|
|
21
|
+
export function deadline(ms: number, parent?: AbortSignal): Deadline {
|
|
22
|
+
const controller = new AbortController()
|
|
23
|
+
const timer = setTimeout(() => controller.abort(new DOMException('The operation timed out.', 'TimeoutError')), ms)
|
|
24
|
+
return {
|
|
25
|
+
signal: parent === undefined ? controller.signal : AbortSignal.any([parent, controller.signal]),
|
|
26
|
+
clear: () => clearTimeout(timer),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
11
30
|
/** A handler's `ctx.signal`: aborted when the client leaves (`request.signal`) or once `budgetMs` passed. */
|
|
12
|
-
export function handlerSignal(request: Request, budgetMs: number = REQUEST_BUDGET_MS):
|
|
13
|
-
return
|
|
31
|
+
export function handlerSignal(request: Request, budgetMs: number = REQUEST_BUDGET_MS): Deadline {
|
|
32
|
+
return deadline(budgetMs, request.signal)
|
|
14
33
|
}
|
|
15
34
|
|
|
16
35
|
export interface BoundedRead {
|