@atomservice/functions-sdk 0.1.6
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 +36 -0
- package/src/index.ts +33 -0
- package/src/sdk.consts.ts +15 -0
- package/src/sdk.consumer.ts +170 -0
- package/src/sdk.context.ts +25 -0
- package/src/sdk.define.ts +91 -0
- package/src/sdk.errors.ts +56 -0
- package/src/sdk.executor.ts +270 -0
- package/src/sdk.invoke.ts +140 -0
- package/src/sdk.log.ts +44 -0
- package/src/sdk.run.ts +43 -0
- package/src/sdk.runtime.ts +177 -0
- package/src/sdk.types.ts +94 -0
- package/src/sdk.validate.ts +11 -0
- package/src/sdk.worker.ts +43 -0
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@atomservice/functions-sdk",
|
|
3
|
+
"version": "0.1.6",
|
|
4
|
+
"description": "函数作者 SDK:定义器(defineFunction 等)、event 类型推导、schema 校验、结构化日志、内置 HTTP 运行时",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "openorson",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/openorson/atomservice.git",
|
|
10
|
+
"directory": "services/functions/sdk"
|
|
11
|
+
},
|
|
12
|
+
"bugs": "https://github.com/openorson/atomservice/issues",
|
|
13
|
+
"homepage": "https://github.com/openorson/atomservice/tree/main/services/functions/sdk#readme",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"atomservice",
|
|
16
|
+
"functions",
|
|
17
|
+
"faas",
|
|
18
|
+
"sdk",
|
|
19
|
+
"bun"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"bun": ">=1.3.14"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./src/index.ts"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"typebox": "1.2.2"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"typescript": "^6"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export {
|
|
2
|
+
defineAsyncFunction,
|
|
3
|
+
defineAsyncStreamFunction,
|
|
4
|
+
defineFunction,
|
|
5
|
+
defineStreamFunction,
|
|
6
|
+
} from "./sdk.define.ts"
|
|
7
|
+
export type {
|
|
8
|
+
BusinessErrorPayload,
|
|
9
|
+
ErrorPayload,
|
|
10
|
+
ResultEnvelope,
|
|
11
|
+
SystemErrorPayload,
|
|
12
|
+
} from "./sdk.errors.ts"
|
|
13
|
+
export { BusinessError, isBusinessError, isSystemError, SystemError } from "./sdk.errors.ts"
|
|
14
|
+
export type { LogContext, LogLevel, LogRecord, LogSink } from "./sdk.log.ts"
|
|
15
|
+
export { createLogger, stdoutSink } from "./sdk.log.ts"
|
|
16
|
+
export type { ServeOptions } from "./sdk.runtime.ts"
|
|
17
|
+
export { serve } from "./sdk.runtime.ts"
|
|
18
|
+
export type {
|
|
19
|
+
AnyFunctionDefinition,
|
|
20
|
+
AsyncFunctionDefinition,
|
|
21
|
+
AsyncStreamFunctionDefinition,
|
|
22
|
+
DeepPartial,
|
|
23
|
+
ErrorsSchema,
|
|
24
|
+
ExecutionMode,
|
|
25
|
+
FunctionConfig,
|
|
26
|
+
FunctionContext,
|
|
27
|
+
FunctionDefinition,
|
|
28
|
+
FunctionKind,
|
|
29
|
+
FunctionRegistry,
|
|
30
|
+
LogFields,
|
|
31
|
+
Logger,
|
|
32
|
+
StreamFunctionDefinition,
|
|
33
|
+
} from "./sdk.types.ts"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const INVOKE_PATH_PREFIX = "/__invoke/"
|
|
2
|
+
export const HEALTH_PATH = "/__health"
|
|
3
|
+
export const DEFAULT_BUNDLE_PORT = 8080
|
|
4
|
+
export const REQUEST_ID_HEADER = "x-atomfn-request-id"
|
|
5
|
+
|
|
6
|
+
export const SYSTEM_ERROR = {
|
|
7
|
+
NOT_FOUND: "NOT_FOUND",
|
|
8
|
+
VALIDATION_FAILED: "VALIDATION_FAILED",
|
|
9
|
+
OUTPUT_INVALID: "OUTPUT_INVALID",
|
|
10
|
+
FUNCTION_ERROR: "FUNCTION_ERROR",
|
|
11
|
+
FUNCTION_TIMEOUT: "FUNCTION_TIMEOUT",
|
|
12
|
+
OVERLOADED: "OVERLOADED",
|
|
13
|
+
} as const
|
|
14
|
+
|
|
15
|
+
export type SystemErrorCode = (typeof SYSTEM_ERROR)[keyof typeof SYSTEM_ERROR]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { SQL } from "bun"
|
|
2
|
+
import type { ContextFactory } from "./sdk.executor.ts"
|
|
3
|
+
import { type RawOutcome, runOneShot, runStream } from "./sdk.run.ts"
|
|
4
|
+
import type { AnyFunctionDefinition, FunctionRegistry } from "./sdk.types.ts"
|
|
5
|
+
import { validate } from "./sdk.validate.ts"
|
|
6
|
+
|
|
7
|
+
export interface ConsumerOptions {
|
|
8
|
+
databaseUrl: string
|
|
9
|
+
project: string
|
|
10
|
+
bundle: string
|
|
11
|
+
registry: FunctionRegistry
|
|
12
|
+
makeContext: ContextFactory
|
|
13
|
+
pollIntervalMs?: number
|
|
14
|
+
visibilityTimeoutSec?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ClaimedJob {
|
|
18
|
+
id: string
|
|
19
|
+
fn: string
|
|
20
|
+
event: unknown
|
|
21
|
+
webhook: string | null
|
|
22
|
+
attempts: number
|
|
23
|
+
maxAttempts: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isAsyncKind(def: AnyFunctionDefinition): boolean {
|
|
27
|
+
return def.kind === "asyncFunction" || def.kind === "asyncStreamFunction"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
31
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function deepMerge(target: unknown, patch: unknown): unknown {
|
|
35
|
+
if (!isPlainObject(target) || !isPlainObject(patch)) return patch
|
|
36
|
+
const result: Record<string, unknown> = { ...target }
|
|
37
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
38
|
+
result[key] = isPlainObject(result[key]) && isPlainObject(value) ? deepMerge(result[key], value) : value
|
|
39
|
+
}
|
|
40
|
+
return result
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sleep(ms: number): Promise<void> {
|
|
44
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function startConsumer(options: ConsumerOptions): () => void {
|
|
48
|
+
const asyncNames = Object.entries(options.registry)
|
|
49
|
+
.filter(([, def]) => isAsyncKind(def))
|
|
50
|
+
.map(([name]) => name)
|
|
51
|
+
if (asyncNames.length === 0) return () => {}
|
|
52
|
+
|
|
53
|
+
const sql = new SQL(options.databaseUrl)
|
|
54
|
+
const interval = options.pollIntervalMs ?? 1000
|
|
55
|
+
const visibilitySec = options.visibilityTimeoutSec ?? 300
|
|
56
|
+
const asyncNamesLiteral = `{${asyncNames.join(",")}}`
|
|
57
|
+
let stopped = false
|
|
58
|
+
|
|
59
|
+
async function claim(): Promise<ClaimedJob | null> {
|
|
60
|
+
const rows = (await sql`
|
|
61
|
+
UPDATE "Job" SET status = 'running', "lockedAt" = now(), attempts = attempts + 1, "updatedAt" = now()
|
|
62
|
+
WHERE id = (
|
|
63
|
+
SELECT id FROM "Job"
|
|
64
|
+
WHERE project = ${options.project} AND bundle = ${options.bundle}
|
|
65
|
+
AND fn = ANY(${asyncNamesLiteral}::text[])
|
|
66
|
+
AND "availableAt" <= now()
|
|
67
|
+
AND (
|
|
68
|
+
status = 'queued'
|
|
69
|
+
OR (status = 'running' AND "lockedAt" < now() - make_interval(secs => ${visibilitySec}))
|
|
70
|
+
)
|
|
71
|
+
ORDER BY "availableAt"
|
|
72
|
+
FOR UPDATE SKIP LOCKED LIMIT 1
|
|
73
|
+
)
|
|
74
|
+
RETURNING id, fn, event, webhook, attempts, "maxAttempts"
|
|
75
|
+
`) as ClaimedJob[]
|
|
76
|
+
return rows[0] ?? null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function execute(job: ClaimedJob): Promise<RawOutcome> {
|
|
80
|
+
const def = options.registry[job.fn]
|
|
81
|
+
if (!def) return { kind: "crash", message: `未注册函数 ${job.fn}` }
|
|
82
|
+
const ctx = options.makeContext(job.fn, job.id)
|
|
83
|
+
|
|
84
|
+
const rawEvent = typeof job.event === "string" ? JSON.parse(job.event) : job.event
|
|
85
|
+
const parsed = validate(def.input, rawEvent)
|
|
86
|
+
if (!parsed.ok) return { kind: "crash", message: `入参校验失败:${parsed.issues.join("; ")}` }
|
|
87
|
+
const event = parsed.value
|
|
88
|
+
|
|
89
|
+
if (def.kind === "asyncStreamFunction") {
|
|
90
|
+
const generator = runStream(def, event, ctx)
|
|
91
|
+
let merged: unknown = {}
|
|
92
|
+
while (true) {
|
|
93
|
+
const next = await generator.next()
|
|
94
|
+
if (next.done) {
|
|
95
|
+
if (next.value.kind === "ok") return { kind: "ok", data: merged }
|
|
96
|
+
return next.value
|
|
97
|
+
}
|
|
98
|
+
merged = deepMerge(merged, next.value)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return runOneShot(def, event, ctx)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function finalize(job: ClaimedJob, outcome: RawOutcome): Promise<void> {
|
|
105
|
+
if (outcome.kind === "ok") {
|
|
106
|
+
await sql`UPDATE "Job" SET status = 'done', result = ${JSON.stringify(outcome.data ?? null)}::jsonb, error = NULL, "updatedAt" = now() WHERE id = ${job.id}`
|
|
107
|
+
await notify(job, "done")
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (outcome.kind === "business") {
|
|
112
|
+
const error = { type: "business", code: outcome.code, data: outcome.data }
|
|
113
|
+
await sql`UPDATE "Job" SET status = 'failed', error = ${JSON.stringify(error)}::jsonb, "updatedAt" = now() WHERE id = ${job.id}`
|
|
114
|
+
await notify(job, "failed")
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const error = { type: "system", code: "FUNCTION_ERROR", message: outcome.message, retryable: true }
|
|
119
|
+
if (job.attempts < job.maxAttempts) {
|
|
120
|
+
const backoffSec = Math.min(2 ** job.attempts, 300)
|
|
121
|
+
await sql`UPDATE "Job" SET status = 'queued', error = ${JSON.stringify(error)}::jsonb, "lockedAt" = NULL, "availableAt" = now() + make_interval(secs => ${backoffSec}), "updatedAt" = now() WHERE id = ${job.id}`
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
await sql`UPDATE "Job" SET status = 'failed', error = ${JSON.stringify(error)}::jsonb, "updatedAt" = now() WHERE id = ${job.id}`
|
|
125
|
+
await notify(job, "failed")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function notify(job: ClaimedJob, status: "done" | "failed"): Promise<void> {
|
|
129
|
+
if (!job.webhook) return
|
|
130
|
+
await fetch(job.webhook, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "content-type": "application/json" },
|
|
133
|
+
body: JSON.stringify({ jobId: job.id, status }),
|
|
134
|
+
}).catch(() => {})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const heartbeatIntervalMs = Math.floor((visibilitySec / 3) * 1000)
|
|
138
|
+
|
|
139
|
+
function startHeartbeat(jobId: string): () => void {
|
|
140
|
+
const timer = setInterval(() => {
|
|
141
|
+
sql`UPDATE "Job" SET "lockedAt" = now() WHERE id = ${jobId} AND status = 'running'`.catch(() => {})
|
|
142
|
+
}, heartbeatIntervalMs)
|
|
143
|
+
return () => clearInterval(timer)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function loop(): Promise<void> {
|
|
147
|
+
while (!stopped) {
|
|
148
|
+
let job: ClaimedJob | null = null
|
|
149
|
+
try {
|
|
150
|
+
job = await claim()
|
|
151
|
+
} catch {
|
|
152
|
+
await sleep(interval)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
if (!job) {
|
|
156
|
+
await sleep(interval)
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
const stopHeartbeat = startHeartbeat(job.id)
|
|
160
|
+
const outcome = await execute(job)
|
|
161
|
+
stopHeartbeat()
|
|
162
|
+
await finalize(job, outcome).catch(() => {})
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
void loop()
|
|
167
|
+
return () => {
|
|
168
|
+
stopped = true
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { BusinessError } from "./sdk.errors.ts"
|
|
2
|
+
import { createLogger, type LogSink } from "./sdk.log.ts"
|
|
3
|
+
import type { ErrorsSchema, FunctionContext } from "./sdk.types.ts"
|
|
4
|
+
|
|
5
|
+
export interface ContextInput {
|
|
6
|
+
project: string
|
|
7
|
+
bundle: string
|
|
8
|
+
function: string
|
|
9
|
+
requestId: string
|
|
10
|
+
sink: LogSink
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createContext<TErrors extends ErrorsSchema>(input: ContextInput): FunctionContext<TErrors> {
|
|
14
|
+
const log = createLogger(
|
|
15
|
+
{ project: input.project, bundle: input.bundle, function: input.function, requestId: input.requestId },
|
|
16
|
+
input.sink,
|
|
17
|
+
)
|
|
18
|
+
return {
|
|
19
|
+
requestId: input.requestId,
|
|
20
|
+
log,
|
|
21
|
+
error(code, data) {
|
|
22
|
+
return new BusinessError(code, data)
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Static, TSchema } from "typebox"
|
|
2
|
+
import type {
|
|
3
|
+
AsyncFunctionDefinition,
|
|
4
|
+
AsyncStreamFunctionDefinition,
|
|
5
|
+
DeepPartial,
|
|
6
|
+
ErrorsSchema,
|
|
7
|
+
FunctionConfig,
|
|
8
|
+
FunctionContext,
|
|
9
|
+
FunctionDefinition,
|
|
10
|
+
StreamFunctionDefinition,
|
|
11
|
+
} from "./sdk.types.ts"
|
|
12
|
+
|
|
13
|
+
interface OneShotInput<TInput extends TSchema, TOutput extends TSchema, TErrors extends ErrorsSchema> {
|
|
14
|
+
input: TInput
|
|
15
|
+
output: TOutput
|
|
16
|
+
errors?: TErrors
|
|
17
|
+
config?: FunctionConfig
|
|
18
|
+
handler: (event: Static<TInput>, ctx: FunctionContext<TErrors>) => Bun.MaybePromise<Static<TOutput>>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface StreamInput<TInput extends TSchema, TOutput extends TSchema, TErrors extends ErrorsSchema> {
|
|
22
|
+
input: TInput
|
|
23
|
+
output: TOutput
|
|
24
|
+
errors?: TErrors
|
|
25
|
+
config?: FunctionConfig
|
|
26
|
+
handler: (
|
|
27
|
+
event: Static<TInput>,
|
|
28
|
+
ctx: FunctionContext<TErrors>,
|
|
29
|
+
) => AsyncGenerator<DeepPartial<Static<TOutput>>, void, void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const EMPTY_ERRORS = {} as ErrorsSchema
|
|
33
|
+
const EMPTY_CONFIG: FunctionConfig = {}
|
|
34
|
+
|
|
35
|
+
export function defineFunction<TInput extends TSchema, TOutput extends TSchema, const TErrors extends ErrorsSchema>(
|
|
36
|
+
def: OneShotInput<TInput, TOutput, TErrors>,
|
|
37
|
+
): FunctionDefinition<TInput, TOutput, TErrors> {
|
|
38
|
+
return {
|
|
39
|
+
kind: "function",
|
|
40
|
+
input: def.input,
|
|
41
|
+
output: def.output,
|
|
42
|
+
errors: (def.errors ?? EMPTY_ERRORS) as TErrors,
|
|
43
|
+
config: def.config ?? EMPTY_CONFIG,
|
|
44
|
+
handler: def.handler,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function defineStreamFunction<
|
|
49
|
+
TInput extends TSchema,
|
|
50
|
+
TOutput extends TSchema,
|
|
51
|
+
const TErrors extends ErrorsSchema,
|
|
52
|
+
>(def: StreamInput<TInput, TOutput, TErrors>): StreamFunctionDefinition<TInput, TOutput, TErrors> {
|
|
53
|
+
return {
|
|
54
|
+
kind: "streamFunction",
|
|
55
|
+
input: def.input,
|
|
56
|
+
output: def.output,
|
|
57
|
+
errors: (def.errors ?? EMPTY_ERRORS) as TErrors,
|
|
58
|
+
config: def.config ?? EMPTY_CONFIG,
|
|
59
|
+
handler: def.handler,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function defineAsyncFunction<
|
|
64
|
+
TInput extends TSchema,
|
|
65
|
+
TOutput extends TSchema,
|
|
66
|
+
const TErrors extends ErrorsSchema,
|
|
67
|
+
>(def: OneShotInput<TInput, TOutput, TErrors>): AsyncFunctionDefinition<TInput, TOutput, TErrors> {
|
|
68
|
+
return {
|
|
69
|
+
kind: "asyncFunction",
|
|
70
|
+
input: def.input,
|
|
71
|
+
output: def.output,
|
|
72
|
+
errors: (def.errors ?? EMPTY_ERRORS) as TErrors,
|
|
73
|
+
config: def.config ?? EMPTY_CONFIG,
|
|
74
|
+
handler: def.handler,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function defineAsyncStreamFunction<
|
|
79
|
+
TInput extends TSchema,
|
|
80
|
+
TOutput extends TSchema,
|
|
81
|
+
const TErrors extends ErrorsSchema,
|
|
82
|
+
>(def: StreamInput<TInput, TOutput, TErrors>): AsyncStreamFunctionDefinition<TInput, TOutput, TErrors> {
|
|
83
|
+
return {
|
|
84
|
+
kind: "asyncStreamFunction",
|
|
85
|
+
input: def.input,
|
|
86
|
+
output: def.output,
|
|
87
|
+
errors: (def.errors ?? EMPTY_ERRORS) as TErrors,
|
|
88
|
+
config: def.config ?? EMPTY_CONFIG,
|
|
89
|
+
handler: def.handler,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export interface BusinessErrorPayload {
|
|
2
|
+
type: "business"
|
|
3
|
+
code: string
|
|
4
|
+
data: unknown
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface SystemErrorPayload {
|
|
8
|
+
type: "system"
|
|
9
|
+
code: string
|
|
10
|
+
message: string
|
|
11
|
+
retryable: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ErrorPayload = BusinessErrorPayload | SystemErrorPayload
|
|
15
|
+
|
|
16
|
+
export type ResultEnvelope<TOutput = unknown> = { ok: true; data: TOutput } | { ok: false; error: ErrorPayload }
|
|
17
|
+
|
|
18
|
+
export class BusinessError extends Error {
|
|
19
|
+
readonly code: string
|
|
20
|
+
readonly data: unknown
|
|
21
|
+
|
|
22
|
+
constructor(code: string, data: unknown) {
|
|
23
|
+
super(`business error: ${code}`)
|
|
24
|
+
this.name = "BusinessError"
|
|
25
|
+
this.code = code
|
|
26
|
+
this.data = data
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
toPayload(): BusinessErrorPayload {
|
|
30
|
+
return { type: "business", code: this.code, data: this.data }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class SystemError extends Error {
|
|
35
|
+
readonly code: string
|
|
36
|
+
readonly retryable: boolean
|
|
37
|
+
|
|
38
|
+
constructor(code: string, message: string, retryable = false) {
|
|
39
|
+
super(message)
|
|
40
|
+
this.name = "SystemError"
|
|
41
|
+
this.code = code
|
|
42
|
+
this.retryable = retryable
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
toPayload(): SystemErrorPayload {
|
|
46
|
+
return { type: "system", code: this.code, message: this.message, retryable: this.retryable }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isBusinessError(value: unknown): value is BusinessError {
|
|
51
|
+
return value instanceof BusinessError
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isSystemError(value: unknown): value is SystemError {
|
|
55
|
+
return value instanceof SystemError
|
|
56
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { type RawOutcome, runOneShot, runStream } from "./sdk.run.ts"
|
|
2
|
+
import type { FunctionContext, FunctionRegistry } from "./sdk.types.ts"
|
|
3
|
+
|
|
4
|
+
export type ExecOutcome = RawOutcome | { kind: "timeout" }
|
|
5
|
+
|
|
6
|
+
export interface ExecuteInput {
|
|
7
|
+
name: string
|
|
8
|
+
event: unknown
|
|
9
|
+
requestId: string
|
|
10
|
+
timeout: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Executor {
|
|
14
|
+
oneShot(input: ExecuteInput): Promise<ExecOutcome>
|
|
15
|
+
stream(input: ExecuteInput): AsyncGenerator<unknown, ExecOutcome, void>
|
|
16
|
+
dispose(): Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ContextFactory = (name: string, requestId: string) => FunctionContext<Record<string, never>>
|
|
20
|
+
|
|
21
|
+
function timeoutGuard(ms: number): { promise: Promise<{ kind: "timeout" }>; cancel: () => void } {
|
|
22
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
23
|
+
const promise = new Promise<{ kind: "timeout" }>((resolve) => {
|
|
24
|
+
timer = setTimeout(() => resolve({ kind: "timeout" }), ms)
|
|
25
|
+
})
|
|
26
|
+
return { promise, cancel: () => timer && clearTimeout(timer) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class InlineExecutor implements Executor {
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly registry: FunctionRegistry,
|
|
32
|
+
private readonly makeContext: ContextFactory,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
async oneShot({ name, event, requestId, timeout }: ExecuteInput): Promise<ExecOutcome> {
|
|
36
|
+
const def = this.registry[name]
|
|
37
|
+
if (!def) return { kind: "crash", message: `未注册函数 ${name}` }
|
|
38
|
+
const ctx = this.makeContext(name, requestId)
|
|
39
|
+
const guard = timeoutGuard(timeout)
|
|
40
|
+
try {
|
|
41
|
+
return await Promise.race([runOneShot(def, event, ctx), guard.promise])
|
|
42
|
+
} finally {
|
|
43
|
+
guard.cancel()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async *stream({ name, event, requestId, timeout }: ExecuteInput): AsyncGenerator<unknown, ExecOutcome, void> {
|
|
48
|
+
const def = this.registry[name]
|
|
49
|
+
if (!def) return { kind: "crash", message: `未注册函数 ${name}` }
|
|
50
|
+
const ctx = this.makeContext(name, requestId)
|
|
51
|
+
const deadline = Date.now() + timeout
|
|
52
|
+
const generator = runStream(def, event, ctx)
|
|
53
|
+
while (true) {
|
|
54
|
+
const next = await generator.next()
|
|
55
|
+
if (next.done) return next.value
|
|
56
|
+
if (Date.now() > deadline) return { kind: "timeout" }
|
|
57
|
+
yield next.value
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async dispose(): Promise<void> {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface PendingOneShot {
|
|
65
|
+
kind: "oneShot"
|
|
66
|
+
resolve: (outcome: RawOutcome) => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface PendingStream {
|
|
70
|
+
kind: "stream"
|
|
71
|
+
push: (chunk: unknown) => void
|
|
72
|
+
end: (outcome: RawOutcome) => void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type WorkerMessage =
|
|
76
|
+
| { t: "ready" }
|
|
77
|
+
| { t: "result"; id: number; outcome: RawOutcome }
|
|
78
|
+
| { t: "chunk"; id: number; data: unknown }
|
|
79
|
+
| { t: "end"; id: number; outcome: RawOutcome }
|
|
80
|
+
|
|
81
|
+
class WorkerHandle {
|
|
82
|
+
private readonly proc: Bun.Subprocess
|
|
83
|
+
private readonly pending = new Map<number, PendingOneShot | PendingStream>()
|
|
84
|
+
private seq = 0
|
|
85
|
+
private markReady!: () => void
|
|
86
|
+
readonly ready: Promise<void>
|
|
87
|
+
|
|
88
|
+
constructor(args: string[], env: Record<string, string>) {
|
|
89
|
+
this.ready = new Promise((resolve) => {
|
|
90
|
+
this.markReady = resolve
|
|
91
|
+
})
|
|
92
|
+
this.proc = Bun.spawn(args, {
|
|
93
|
+
env,
|
|
94
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
95
|
+
serialization: "json",
|
|
96
|
+
ipc: (message) => this.onMessage(message as WorkerMessage),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private onMessage(message: WorkerMessage): void {
|
|
101
|
+
if (message.t === "ready") {
|
|
102
|
+
this.markReady()
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
const entry = this.pending.get(message.id)
|
|
106
|
+
if (!entry) return
|
|
107
|
+
if (message.t === "result" && entry.kind === "oneShot") {
|
|
108
|
+
this.pending.delete(message.id)
|
|
109
|
+
entry.resolve(message.outcome)
|
|
110
|
+
} else if (message.t === "chunk" && entry.kind === "stream") {
|
|
111
|
+
entry.push(message.data)
|
|
112
|
+
} else if (message.t === "end" && entry.kind === "stream") {
|
|
113
|
+
this.pending.delete(message.id)
|
|
114
|
+
entry.end(message.outcome)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
oneShot(name: string, event: unknown, requestId: string): Promise<RawOutcome> {
|
|
119
|
+
const id = ++this.seq
|
|
120
|
+
return new Promise<RawOutcome>((resolve) => {
|
|
121
|
+
this.pending.set(id, { kind: "oneShot", resolve })
|
|
122
|
+
this.proc.send({ t: "call", id, stream: false, name, event, requestId })
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
stream(name: string, event: unknown, requestId: string): AsyncGenerator<unknown, RawOutcome, void> {
|
|
127
|
+
const id = ++this.seq
|
|
128
|
+
const queue: unknown[] = []
|
|
129
|
+
let terminal: RawOutcome | undefined
|
|
130
|
+
let notify: (() => void) | undefined
|
|
131
|
+
|
|
132
|
+
const wake = () => {
|
|
133
|
+
const fn = notify
|
|
134
|
+
notify = undefined
|
|
135
|
+
fn?.()
|
|
136
|
+
}
|
|
137
|
+
this.pending.set(id, {
|
|
138
|
+
kind: "stream",
|
|
139
|
+
push: (chunk) => {
|
|
140
|
+
queue.push(chunk)
|
|
141
|
+
wake()
|
|
142
|
+
},
|
|
143
|
+
end: (outcome) => {
|
|
144
|
+
terminal = outcome
|
|
145
|
+
wake()
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
this.proc.send({ t: "call", id, stream: true, name, event, requestId })
|
|
149
|
+
|
|
150
|
+
async function* drain(): AsyncGenerator<unknown, RawOutcome, void> {
|
|
151
|
+
while (true) {
|
|
152
|
+
if (queue.length > 0) {
|
|
153
|
+
yield queue.shift()
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if (terminal) return terminal
|
|
157
|
+
await new Promise<void>((resolve) => {
|
|
158
|
+
notify = resolve
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return drain()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
kill(): void {
|
|
166
|
+
this.proc.kill()
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface ProcessExecutorOptions {
|
|
171
|
+
args: string[]
|
|
172
|
+
env: Record<string, string>
|
|
173
|
+
pooled: boolean
|
|
174
|
+
size: number
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export class ProcessExecutor implements Executor {
|
|
178
|
+
private readonly idle: WorkerHandle[] = []
|
|
179
|
+
private readonly waiters: Array<(worker: WorkerHandle) => void> = []
|
|
180
|
+
private created = 0
|
|
181
|
+
|
|
182
|
+
constructor(private readonly options: ProcessExecutorOptions) {}
|
|
183
|
+
|
|
184
|
+
private spawn(): WorkerHandle {
|
|
185
|
+
return new WorkerHandle(this.options.args, { ...this.options.env, ATOMFN_WORKER: "1" })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async acquire(): Promise<WorkerHandle> {
|
|
189
|
+
if (!this.options.pooled) {
|
|
190
|
+
const worker = this.spawn()
|
|
191
|
+
await worker.ready
|
|
192
|
+
return worker
|
|
193
|
+
}
|
|
194
|
+
const existing = this.idle.pop()
|
|
195
|
+
if (existing) return existing
|
|
196
|
+
if (this.created < this.options.size) {
|
|
197
|
+
this.created += 1
|
|
198
|
+
const worker = this.spawn()
|
|
199
|
+
await worker.ready
|
|
200
|
+
return worker
|
|
201
|
+
}
|
|
202
|
+
return new Promise<WorkerHandle>((resolve) => this.waiters.push(resolve))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private release(worker: WorkerHandle): void {
|
|
206
|
+
if (!this.options.pooled) {
|
|
207
|
+
worker.kill()
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
const waiter = this.waiters.shift()
|
|
211
|
+
if (waiter) waiter(worker)
|
|
212
|
+
else this.idle.push(worker)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private discard(worker: WorkerHandle): void {
|
|
216
|
+
worker.kill()
|
|
217
|
+
if (!this.options.pooled) return
|
|
218
|
+
this.created -= 1
|
|
219
|
+
const waiter = this.waiters.shift()
|
|
220
|
+
if (waiter) {
|
|
221
|
+
this.created += 1
|
|
222
|
+
const replacement = this.spawn()
|
|
223
|
+
replacement.ready.then(() => waiter(replacement))
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async oneShot({ name, event, requestId, timeout }: ExecuteInput): Promise<ExecOutcome> {
|
|
228
|
+
const worker = await this.acquire()
|
|
229
|
+
const guard = timeoutGuard(timeout)
|
|
230
|
+
try {
|
|
231
|
+
const outcome = await Promise.race([worker.oneShot(name, event, requestId), guard.promise])
|
|
232
|
+
if (outcome.kind === "timeout") {
|
|
233
|
+
this.discard(worker)
|
|
234
|
+
return outcome
|
|
235
|
+
}
|
|
236
|
+
this.release(worker)
|
|
237
|
+
return outcome
|
|
238
|
+
} finally {
|
|
239
|
+
guard.cancel()
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async *stream({ name, event, requestId, timeout }: ExecuteInput): AsyncGenerator<unknown, ExecOutcome, void> {
|
|
244
|
+
const worker = await this.acquire()
|
|
245
|
+
const deadline = Date.now() + timeout
|
|
246
|
+
const generator = worker.stream(name, event, requestId)
|
|
247
|
+
try {
|
|
248
|
+
while (true) {
|
|
249
|
+
const next = await generator.next()
|
|
250
|
+
if (next.done) {
|
|
251
|
+
this.release(worker)
|
|
252
|
+
return next.value
|
|
253
|
+
}
|
|
254
|
+
if (Date.now() > deadline) {
|
|
255
|
+
this.discard(worker)
|
|
256
|
+
return { kind: "timeout" }
|
|
257
|
+
}
|
|
258
|
+
yield next.value
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
this.discard(worker)
|
|
262
|
+
return { kind: "crash", message: error instanceof Error ? error.message : String(error) }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async dispose(): Promise<void> {
|
|
267
|
+
for (const worker of this.idle) worker.kill()
|
|
268
|
+
this.idle.length = 0
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { SYSTEM_ERROR } from "./sdk.consts.ts"
|
|
2
|
+
import type { ResultEnvelope, SystemErrorPayload } from "./sdk.errors.ts"
|
|
3
|
+
import type { ExecOutcome, Executor } from "./sdk.executor.ts"
|
|
4
|
+
import type {
|
|
5
|
+
AnyFunctionDefinition,
|
|
6
|
+
AsyncFunctionDefinition,
|
|
7
|
+
AsyncStreamFunctionDefinition,
|
|
8
|
+
FunctionDefinition,
|
|
9
|
+
StreamFunctionDefinition,
|
|
10
|
+
} from "./sdk.types.ts"
|
|
11
|
+
import { validate } from "./sdk.validate.ts"
|
|
12
|
+
|
|
13
|
+
export interface InvokeDeps {
|
|
14
|
+
executor: Executor
|
|
15
|
+
name: string
|
|
16
|
+
requestId: string
|
|
17
|
+
defaultTimeout: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OneShotResult {
|
|
21
|
+
status: number
|
|
22
|
+
envelope: ResultEnvelope
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function systemError(code: string, message: string, retryable = false): SystemErrorPayload {
|
|
26
|
+
return { type: "system", code, message, retryable }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mapTerminal(def: AnyFunctionDefinition, outcome: ExecOutcome): OneShotResult {
|
|
30
|
+
if (outcome.kind === "timeout") {
|
|
31
|
+
return { status: 504, envelope: { ok: false, error: systemError(SYSTEM_ERROR.FUNCTION_TIMEOUT, "执行超时") } }
|
|
32
|
+
}
|
|
33
|
+
if (outcome.kind === "business") {
|
|
34
|
+
return {
|
|
35
|
+
status: 200,
|
|
36
|
+
envelope: { ok: false, error: { type: "business", code: outcome.code, data: outcome.data } },
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (outcome.kind === "crash") {
|
|
40
|
+
return { status: 500, envelope: { ok: false, error: systemError(SYSTEM_ERROR.FUNCTION_ERROR, outcome.message) } }
|
|
41
|
+
}
|
|
42
|
+
const checked = validate(def.output, outcome.data)
|
|
43
|
+
if (!checked.ok) {
|
|
44
|
+
return {
|
|
45
|
+
status: 500,
|
|
46
|
+
envelope: { ok: false, error: systemError(SYSTEM_ERROR.OUTPUT_INVALID, checked.issues.join("; ")) },
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { status: 200, envelope: { ok: true, data: checked.value } }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function invokeOneShot(
|
|
53
|
+
def: FunctionDefinition | AsyncFunctionDefinition,
|
|
54
|
+
body: unknown,
|
|
55
|
+
deps: InvokeDeps,
|
|
56
|
+
): Promise<OneShotResult> {
|
|
57
|
+
const parsed = validate(def.input, body)
|
|
58
|
+
if (!parsed.ok) {
|
|
59
|
+
return {
|
|
60
|
+
status: 422,
|
|
61
|
+
envelope: { ok: false, error: systemError(SYSTEM_ERROR.VALIDATION_FAILED, parsed.issues.join("; ")) },
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const timeout = def.config.timeout ?? deps.defaultTimeout
|
|
66
|
+
const outcome = await deps.executor.oneShot({
|
|
67
|
+
name: deps.name,
|
|
68
|
+
event: parsed.value,
|
|
69
|
+
requestId: deps.requestId,
|
|
70
|
+
timeout,
|
|
71
|
+
})
|
|
72
|
+
return mapTerminal(def, outcome)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SSE_HEADERS = { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
|
|
76
|
+
|
|
77
|
+
function sseEvent(event: string, data: unknown): string {
|
|
78
|
+
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function invokeStream(
|
|
82
|
+
def: StreamFunctionDefinition | AsyncStreamFunctionDefinition,
|
|
83
|
+
body: unknown,
|
|
84
|
+
deps: InvokeDeps,
|
|
85
|
+
): Response {
|
|
86
|
+
const parsed = validate(def.input, body)
|
|
87
|
+
if (!parsed.ok) {
|
|
88
|
+
const envelope: ResultEnvelope = {
|
|
89
|
+
ok: false,
|
|
90
|
+
error: systemError(SYSTEM_ERROR.VALIDATION_FAILED, parsed.issues.join("; ")),
|
|
91
|
+
}
|
|
92
|
+
return Response.json(envelope, { status: 422 })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const timeout = def.config.timeout ?? deps.defaultTimeout
|
|
96
|
+
const encoder = new TextEncoder()
|
|
97
|
+
|
|
98
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
99
|
+
async start(controller) {
|
|
100
|
+
const generator = deps.executor.stream({
|
|
101
|
+
name: deps.name,
|
|
102
|
+
event: parsed.value,
|
|
103
|
+
requestId: deps.requestId,
|
|
104
|
+
timeout,
|
|
105
|
+
})
|
|
106
|
+
while (true) {
|
|
107
|
+
const next = await generator.next()
|
|
108
|
+
if (next.done) {
|
|
109
|
+
const outcome = next.value
|
|
110
|
+
if (outcome.kind === "ok") {
|
|
111
|
+
controller.enqueue(encoder.encode(sseEvent("done", {})))
|
|
112
|
+
} else if (outcome.kind === "business") {
|
|
113
|
+
controller.enqueue(
|
|
114
|
+
encoder.encode(sseEvent("error", { type: "business", code: outcome.code, data: outcome.data })),
|
|
115
|
+
)
|
|
116
|
+
} else if (outcome.kind === "timeout") {
|
|
117
|
+
controller.enqueue(
|
|
118
|
+
encoder.encode(sseEvent("error", systemError(SYSTEM_ERROR.FUNCTION_TIMEOUT, "执行超时"))),
|
|
119
|
+
)
|
|
120
|
+
} else {
|
|
121
|
+
controller.enqueue(
|
|
122
|
+
encoder.encode(sseEvent("error", systemError(SYSTEM_ERROR.FUNCTION_ERROR, outcome.message))),
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
controller.enqueue(encoder.encode(sseEvent("chunk", next.value)))
|
|
128
|
+
}
|
|
129
|
+
controller.close()
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
return new Response(stream, { headers: SSE_HEADERS })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function isStreamKind(
|
|
137
|
+
def: AnyFunctionDefinition,
|
|
138
|
+
): def is StreamFunctionDefinition | AsyncStreamFunctionDefinition {
|
|
139
|
+
return def.kind === "streamFunction" || def.kind === "asyncStreamFunction"
|
|
140
|
+
}
|
package/src/sdk.log.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { LogFields, Logger } from "./sdk.types.ts"
|
|
2
|
+
|
|
3
|
+
export type LogLevel = "debug" | "info" | "warn" | "error"
|
|
4
|
+
|
|
5
|
+
export interface LogRecord {
|
|
6
|
+
ts: string
|
|
7
|
+
level: LogLevel
|
|
8
|
+
project: string
|
|
9
|
+
bundle: string
|
|
10
|
+
function: string
|
|
11
|
+
requestId: string
|
|
12
|
+
msg: string
|
|
13
|
+
fields?: LogFields
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LogSink {
|
|
17
|
+
write(record: LogRecord): void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LogContext {
|
|
21
|
+
project: string
|
|
22
|
+
bundle: string
|
|
23
|
+
function: string
|
|
24
|
+
requestId: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const stdoutSink: LogSink = {
|
|
28
|
+
write(record) {
|
|
29
|
+
const { fields, ...rest } = record
|
|
30
|
+
process.stdout.write(`${JSON.stringify({ ...rest, ...fields })}\n`)
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createLogger(ctx: LogContext, sink: LogSink): Logger {
|
|
35
|
+
const emit = (level: LogLevel, msg: string, fields?: LogFields) => {
|
|
36
|
+
sink.write({ ts: new Date().toISOString(), level, ...ctx, msg, fields })
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
debug: (msg, fields) => emit("debug", msg, fields),
|
|
40
|
+
info: (msg, fields) => emit("info", msg, fields),
|
|
41
|
+
warn: (msg, fields) => emit("warn", msg, fields),
|
|
42
|
+
error: (msg, fields) => emit("error", msg, fields),
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/sdk.run.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { BusinessError } from "./sdk.errors.ts"
|
|
2
|
+
import type { AnyFunctionDefinition, FunctionContext } from "./sdk.types.ts"
|
|
3
|
+
|
|
4
|
+
export type RawOutcome =
|
|
5
|
+
| { kind: "ok"; data: unknown }
|
|
6
|
+
| { kind: "business"; code: string; data: unknown }
|
|
7
|
+
| { kind: "crash"; message: string }
|
|
8
|
+
|
|
9
|
+
function crashMessage(error: unknown): string {
|
|
10
|
+
return error instanceof Error ? error.message : String(error)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function fromError(error: unknown): RawOutcome {
|
|
14
|
+
if (error instanceof BusinessError) return { kind: "business", code: error.code, data: error.data }
|
|
15
|
+
return { kind: "crash", message: crashMessage(error) }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function runOneShot(
|
|
19
|
+
def: AnyFunctionDefinition,
|
|
20
|
+
event: unknown,
|
|
21
|
+
ctx: FunctionContext<Record<string, never>>,
|
|
22
|
+
): Promise<RawOutcome> {
|
|
23
|
+
try {
|
|
24
|
+
const data = await (def.handler as (e: unknown, c: unknown) => unknown)(event, ctx)
|
|
25
|
+
return { kind: "ok", data }
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return fromError(error)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function* runStream(
|
|
32
|
+
def: AnyFunctionDefinition,
|
|
33
|
+
event: unknown,
|
|
34
|
+
ctx: FunctionContext<Record<string, never>>,
|
|
35
|
+
): AsyncGenerator<unknown, RawOutcome, void> {
|
|
36
|
+
try {
|
|
37
|
+
const iterator = (def.handler as (e: unknown, c: unknown) => AsyncIterable<unknown>)(event, ctx)
|
|
38
|
+
for await (const partial of iterator) yield partial
|
|
39
|
+
return { kind: "ok", data: undefined }
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return fromError(error)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { DEFAULT_BUNDLE_PORT, HEALTH_PATH, INVOKE_PATH_PREFIX, REQUEST_ID_HEADER, SYSTEM_ERROR } from "./sdk.consts.ts"
|
|
2
|
+
import { startConsumer } from "./sdk.consumer.ts"
|
|
3
|
+
import { createContext } from "./sdk.context.ts"
|
|
4
|
+
import { type ContextFactory, type Executor, InlineExecutor, ProcessExecutor } from "./sdk.executor.ts"
|
|
5
|
+
import { invokeOneShot, invokeStream, isStreamKind } from "./sdk.invoke.ts"
|
|
6
|
+
import { type LogSink, stdoutSink } from "./sdk.log.ts"
|
|
7
|
+
import type { AnyFunctionDefinition, ExecutionMode, FunctionRegistry } from "./sdk.types.ts"
|
|
8
|
+
import { runWorker } from "./sdk.worker.ts"
|
|
9
|
+
|
|
10
|
+
export interface ServeOptions {
|
|
11
|
+
port: number
|
|
12
|
+
project: string
|
|
13
|
+
bundle: string
|
|
14
|
+
sink: LogSink
|
|
15
|
+
defaultTimeout: number
|
|
16
|
+
poolSize: number
|
|
17
|
+
memoryLimitMb?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TIMEOUT = 30_000
|
|
21
|
+
const MEMORY_BACKPRESSURE_RATIO = 0.9
|
|
22
|
+
|
|
23
|
+
function resolveOptions(overrides?: Partial<ServeOptions>): ServeOptions {
|
|
24
|
+
const env = process.env
|
|
25
|
+
const memory = env.ATOMFN_MEMORY_MB ? Number(env.ATOMFN_MEMORY_MB) : undefined
|
|
26
|
+
return {
|
|
27
|
+
port: overrides?.port ?? (env.ATOMFN_PORT ? Number(env.ATOMFN_PORT) : DEFAULT_BUNDLE_PORT),
|
|
28
|
+
project: overrides?.project ?? env.ATOMFN_PROJECT ?? "default",
|
|
29
|
+
bundle: overrides?.bundle ?? env.ATOMFN_BUNDLE ?? "default",
|
|
30
|
+
sink: overrides?.sink ?? stdoutSink,
|
|
31
|
+
defaultTimeout: overrides?.defaultTimeout ?? DEFAULT_TIMEOUT,
|
|
32
|
+
poolSize: overrides?.poolSize ?? Math.max(1, navigator.hardwareConcurrency || 1),
|
|
33
|
+
memoryLimitMb: overrides?.memoryLimitMb ?? (memory && Number.isFinite(memory) ? memory : undefined),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function requestId(request: Request): string {
|
|
38
|
+
return request.headers.get(REQUEST_ID_HEADER) ?? crypto.randomUUID()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeContextFactory(options: ServeOptions): ContextFactory {
|
|
42
|
+
return (name, id) =>
|
|
43
|
+
createContext<Record<string, never>>({
|
|
44
|
+
project: options.project,
|
|
45
|
+
bundle: options.bundle,
|
|
46
|
+
function: name,
|
|
47
|
+
requestId: id,
|
|
48
|
+
sink: options.sink,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface ExecutorRouter {
|
|
53
|
+
pick: (def: AnyFunctionDefinition) => Executor
|
|
54
|
+
dispose: () => Promise<void>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildExecutorRouter(registry: FunctionRegistry, options: ServeOptions): ExecutorRouter {
|
|
58
|
+
const inline = new InlineExecutor(registry, makeContextFactory(options))
|
|
59
|
+
const workerEnv: Record<string, string> = {
|
|
60
|
+
...(process.env as Record<string, string>),
|
|
61
|
+
ATOMFN_PROJECT: options.project,
|
|
62
|
+
ATOMFN_BUNDLE: options.bundle,
|
|
63
|
+
}
|
|
64
|
+
let pooled: ProcessExecutor | undefined
|
|
65
|
+
let isolated: ProcessExecutor | undefined
|
|
66
|
+
|
|
67
|
+
function pick(def: AnyFunctionDefinition): Executor {
|
|
68
|
+
const mode: ExecutionMode = def.config.execution ?? "inline"
|
|
69
|
+
if (mode === "inline") return inline
|
|
70
|
+
if (mode === "pooled") {
|
|
71
|
+
pooled ??= new ProcessExecutor({ args: [...process.argv], env: workerEnv, pooled: true, size: options.poolSize })
|
|
72
|
+
return pooled
|
|
73
|
+
}
|
|
74
|
+
isolated ??= new ProcessExecutor({ args: [...process.argv], env: workerEnv, pooled: false, size: 0 })
|
|
75
|
+
return isolated
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function dispose(): Promise<void> {
|
|
79
|
+
await inline.dispose()
|
|
80
|
+
await pooled?.dispose()
|
|
81
|
+
await isolated?.dispose()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { pick, dispose }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function overloaded(options: ServeOptions): boolean {
|
|
88
|
+
if (!options.memoryLimitMb) return false
|
|
89
|
+
const rssMb = process.memoryUsage().rss / (1024 * 1024)
|
|
90
|
+
return rssMb > options.memoryLimitMb * MEMORY_BACKPRESSURE_RATIO
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function serve(
|
|
94
|
+
registry: FunctionRegistry,
|
|
95
|
+
overrides?: Partial<ServeOptions>,
|
|
96
|
+
): Bun.Server<undefined> | undefined {
|
|
97
|
+
const options = resolveOptions(overrides)
|
|
98
|
+
|
|
99
|
+
if (process.env.ATOMFN_WORKER === "1") {
|
|
100
|
+
runWorker(registry, makeContextFactory(options))
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const router = buildExecutorRouter(registry, options)
|
|
105
|
+
|
|
106
|
+
const databaseUrl = process.env.ATOMFN_DATABASE_URL?.trim()
|
|
107
|
+
if (databaseUrl) {
|
|
108
|
+
startConsumer({
|
|
109
|
+
databaseUrl,
|
|
110
|
+
project: options.project,
|
|
111
|
+
bundle: options.bundle,
|
|
112
|
+
registry,
|
|
113
|
+
makeContext: makeContextFactory(options),
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return Bun.serve({
|
|
118
|
+
port: options.port,
|
|
119
|
+
async fetch(request) {
|
|
120
|
+
const url = new URL(request.url)
|
|
121
|
+
|
|
122
|
+
if (request.method === "GET" && url.pathname === HEALTH_PATH) {
|
|
123
|
+
const functions = Object.entries(registry).map(([name, def]) => ({ name, kind: def.kind }))
|
|
124
|
+
return Response.json({ ok: true, functions })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (request.method === "POST" && url.pathname.startsWith(INVOKE_PATH_PREFIX)) {
|
|
128
|
+
const name = url.pathname.slice(INVOKE_PATH_PREFIX.length)
|
|
129
|
+
const def = registry[name]
|
|
130
|
+
if (!def) {
|
|
131
|
+
return Response.json(
|
|
132
|
+
{
|
|
133
|
+
ok: false,
|
|
134
|
+
error: { type: "system", code: SYSTEM_ERROR.NOT_FOUND, message: `未找到函数 ${name}`, retryable: false },
|
|
135
|
+
},
|
|
136
|
+
{ status: 404 },
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const error = {
|
|
141
|
+
type: "system" as const,
|
|
142
|
+
code: "OVERLOADED",
|
|
143
|
+
message: "内存接近上限,请稍后重试",
|
|
144
|
+
retryable: true,
|
|
145
|
+
}
|
|
146
|
+
if (overloaded(options)) {
|
|
147
|
+
return Response.json({ ok: false, error }, { status: 503, headers: { "retry-after": "1" } })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const id = requestId(request)
|
|
151
|
+
const body = await readBody(request)
|
|
152
|
+
const deps = { executor: router.pick(def), name, requestId: id, defaultTimeout: options.defaultTimeout }
|
|
153
|
+
|
|
154
|
+
if (isStreamKind(def)) {
|
|
155
|
+
const response = invokeStream(def, body, deps)
|
|
156
|
+
response.headers.set(REQUEST_ID_HEADER, id)
|
|
157
|
+
return response
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { status, envelope } = await invokeOneShot(def, body, deps)
|
|
161
|
+
return Response.json(envelope, { status, headers: { [REQUEST_ID_HEADER]: id } })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return new Response("Not Found", { status: 404 })
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function readBody(request: Request): Promise<unknown> {
|
|
170
|
+
const text = await request.text()
|
|
171
|
+
if (!text) return undefined
|
|
172
|
+
try {
|
|
173
|
+
return JSON.parse(text)
|
|
174
|
+
} catch {
|
|
175
|
+
return text
|
|
176
|
+
}
|
|
177
|
+
}
|
package/src/sdk.types.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Static, TSchema } from "typebox"
|
|
2
|
+
import type { BusinessError } from "./sdk.errors.ts"
|
|
3
|
+
|
|
4
|
+
export type ExecutionMode = "inline" | "pooled" | "isolated"
|
|
5
|
+
|
|
6
|
+
export type ErrorsSchema = Record<string, TSchema>
|
|
7
|
+
|
|
8
|
+
export type DeepPartial<T> = T extends (infer U)[]
|
|
9
|
+
? DeepPartial<U>[]
|
|
10
|
+
: T extends object
|
|
11
|
+
? { [K in keyof T]?: DeepPartial<T[K]> }
|
|
12
|
+
: T
|
|
13
|
+
|
|
14
|
+
export interface LogFields {
|
|
15
|
+
[key: string]: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Logger {
|
|
19
|
+
debug(message: string, fields?: LogFields): void
|
|
20
|
+
info(message: string, fields?: LogFields): void
|
|
21
|
+
warn(message: string, fields?: LogFields): void
|
|
22
|
+
error(message: string, fields?: LogFields): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FunctionContext<TErrors extends ErrorsSchema> {
|
|
26
|
+
readonly requestId: string
|
|
27
|
+
readonly log: Logger
|
|
28
|
+
error<TCode extends keyof TErrors & string>(code: TCode, data: Static<TErrors[TCode]>): BusinessError
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FunctionConfig {
|
|
32
|
+
execution?: ExecutionMode
|
|
33
|
+
timeout?: number
|
|
34
|
+
maxConcurrency?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type FunctionKind = "function" | "streamFunction" | "asyncFunction" | "asyncStreamFunction"
|
|
38
|
+
|
|
39
|
+
interface BaseDefinition<TInput extends TSchema, TOutput extends TSchema, TErrors extends ErrorsSchema> {
|
|
40
|
+
readonly input: TInput
|
|
41
|
+
readonly output: TOutput
|
|
42
|
+
readonly errors: TErrors
|
|
43
|
+
readonly config: FunctionConfig
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FunctionDefinition<
|
|
47
|
+
TInput extends TSchema = TSchema,
|
|
48
|
+
TOutput extends TSchema = TSchema,
|
|
49
|
+
TErrors extends ErrorsSchema = ErrorsSchema,
|
|
50
|
+
> extends BaseDefinition<TInput, TOutput, TErrors> {
|
|
51
|
+
readonly kind: "function"
|
|
52
|
+
readonly handler: (event: Static<TInput>, ctx: FunctionContext<TErrors>) => Bun.MaybePromise<Static<TOutput>>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface StreamFunctionDefinition<
|
|
56
|
+
TInput extends TSchema = TSchema,
|
|
57
|
+
TOutput extends TSchema = TSchema,
|
|
58
|
+
TErrors extends ErrorsSchema = ErrorsSchema,
|
|
59
|
+
> extends BaseDefinition<TInput, TOutput, TErrors> {
|
|
60
|
+
readonly kind: "streamFunction"
|
|
61
|
+
readonly handler: (
|
|
62
|
+
event: Static<TInput>,
|
|
63
|
+
ctx: FunctionContext<TErrors>,
|
|
64
|
+
) => AsyncGenerator<DeepPartial<Static<TOutput>>, void, void>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AsyncFunctionDefinition<
|
|
68
|
+
TInput extends TSchema = TSchema,
|
|
69
|
+
TOutput extends TSchema = TSchema,
|
|
70
|
+
TErrors extends ErrorsSchema = ErrorsSchema,
|
|
71
|
+
> extends BaseDefinition<TInput, TOutput, TErrors> {
|
|
72
|
+
readonly kind: "asyncFunction"
|
|
73
|
+
readonly handler: (event: Static<TInput>, ctx: FunctionContext<TErrors>) => Bun.MaybePromise<Static<TOutput>>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface AsyncStreamFunctionDefinition<
|
|
77
|
+
TInput extends TSchema = TSchema,
|
|
78
|
+
TOutput extends TSchema = TSchema,
|
|
79
|
+
TErrors extends ErrorsSchema = ErrorsSchema,
|
|
80
|
+
> extends BaseDefinition<TInput, TOutput, TErrors> {
|
|
81
|
+
readonly kind: "asyncStreamFunction"
|
|
82
|
+
readonly handler: (
|
|
83
|
+
event: Static<TInput>,
|
|
84
|
+
ctx: FunctionContext<TErrors>,
|
|
85
|
+
) => AsyncGenerator<DeepPartial<Static<TOutput>>, void, void>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type AnyFunctionDefinition =
|
|
89
|
+
| FunctionDefinition
|
|
90
|
+
| StreamFunctionDefinition
|
|
91
|
+
| AsyncFunctionDefinition
|
|
92
|
+
| AsyncStreamFunctionDefinition
|
|
93
|
+
|
|
94
|
+
export type FunctionRegistry = Record<string, AnyFunctionDefinition>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TSchema } from "typebox"
|
|
2
|
+
import { Value } from "typebox/value"
|
|
3
|
+
|
|
4
|
+
export type ValidationResult = { ok: true; value: unknown } | { ok: false; issues: string[] }
|
|
5
|
+
|
|
6
|
+
export function validate(schema: TSchema, value: unknown): ValidationResult {
|
|
7
|
+
const filled = Value.Default(schema, Value.Clone(value))
|
|
8
|
+
if (Value.Check(schema, filled)) return { ok: true, value: filled }
|
|
9
|
+
const issues = [...Value.Errors(schema, filled)].map((e) => `${e.instancePath || "/"}: ${e.message}`)
|
|
10
|
+
return { ok: false, issues }
|
|
11
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ContextFactory } from "./sdk.executor.ts"
|
|
2
|
+
import { runOneShot, runStream } from "./sdk.run.ts"
|
|
3
|
+
import type { FunctionRegistry } from "./sdk.types.ts"
|
|
4
|
+
|
|
5
|
+
interface CallMessage {
|
|
6
|
+
t: "call"
|
|
7
|
+
id: number
|
|
8
|
+
stream: boolean
|
|
9
|
+
name: string
|
|
10
|
+
event: unknown
|
|
11
|
+
requestId: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function runWorker(registry: FunctionRegistry, makeContext: ContextFactory): void {
|
|
15
|
+
process.on("message", async (raw: unknown) => {
|
|
16
|
+
const message = raw as CallMessage
|
|
17
|
+
if (message.t !== "call") return
|
|
18
|
+
|
|
19
|
+
const def = registry[message.name]
|
|
20
|
+
if (!def) {
|
|
21
|
+
process.send?.({ t: "result", id: message.id, outcome: { kind: "crash", message: `未注册函数 ${message.name}` } })
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ctx = makeContext(message.name, message.requestId)
|
|
26
|
+
if (message.stream) {
|
|
27
|
+
const generator = runStream(def, message.event, ctx)
|
|
28
|
+
while (true) {
|
|
29
|
+
const next = await generator.next()
|
|
30
|
+
if (next.done) {
|
|
31
|
+
process.send?.({ t: "end", id: message.id, outcome: next.value })
|
|
32
|
+
break
|
|
33
|
+
}
|
|
34
|
+
process.send?.({ t: "chunk", id: message.id, data: next.value })
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
const outcome = await runOneShot(def, message.event, ctx)
|
|
38
|
+
process.send?.({ t: "result", id: message.id, outcome })
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
process.send?.({ t: "ready" })
|
|
43
|
+
}
|