forked from imbytecat/fullstack-starter
feat: 使用Effect模型重构构建流程并引入类型安全支持
- 使用 Effect 模型重构构建流程,引入类型安全的配置验证、服务层解耦和错误处理,提升代码可维护性与可测试性。 - 添加 Effect 平台和模式库依赖及其相关构建工具,支持多平台二进制文件下载与原生模块编译。 - 添加Effect平台和模式库依赖以支持类型安全和平台功能
This commit is contained in:
331
build.ts
331
build.ts
@@ -1,8 +1,9 @@
|
||||
import { Schema } from '@effect/schema'
|
||||
import { $ } from 'bun'
|
||||
import { Console, Effect } from 'effect'
|
||||
import { Console, Context, Data, Effect, Layer } from 'effect'
|
||||
|
||||
// ============================================================================
|
||||
// Domain Models
|
||||
// Domain Models & Schema
|
||||
// ============================================================================
|
||||
|
||||
const targetMap = {
|
||||
@@ -13,141 +14,275 @@ const targetMap = {
|
||||
'bun-linux-arm64': 'aarch64-unknown-linux-gnu',
|
||||
} as const
|
||||
|
||||
type BunTarget = keyof typeof targetMap
|
||||
const BunTargetSchema = Schema.Literal(
|
||||
'bun-windows-x64',
|
||||
'bun-darwin-arm64',
|
||||
'bun-darwin-x64',
|
||||
'bun-linux-x64',
|
||||
'bun-linux-arm64',
|
||||
)
|
||||
|
||||
type BuildConfig = Readonly<{
|
||||
entrypoint: string
|
||||
outputDir: string
|
||||
targets: ReadonlyArray<BunTarget>
|
||||
}>
|
||||
type BunTarget = Schema.Schema.Type<typeof BunTargetSchema>
|
||||
|
||||
type BuildResult = Readonly<{
|
||||
target: BunTarget
|
||||
outputs: ReadonlyArray<string>
|
||||
}>
|
||||
const BuildConfigSchema = Schema.Struct({
|
||||
entrypoint: Schema.String.pipe(Schema.nonEmptyString()),
|
||||
outputDir: Schema.String.pipe(Schema.nonEmptyString()),
|
||||
targets: Schema.Array(BunTargetSchema).pipe(Schema.minItems(1)),
|
||||
})
|
||||
|
||||
type BuildConfig = Schema.Schema.Type<typeof BuildConfigSchema>
|
||||
|
||||
const BuildResultSchema = Schema.Struct({
|
||||
target: BunTargetSchema,
|
||||
outputs: Schema.Array(Schema.String),
|
||||
})
|
||||
|
||||
type BuildResult = Schema.Schema.Type<typeof BuildResultSchema>
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// Error Models (使用 Data.TaggedError)
|
||||
// ============================================================================
|
||||
|
||||
const defaultConfig: BuildConfig = {
|
||||
entrypoint: './.output/server/index.mjs',
|
||||
outputDir: './out',
|
||||
targets: ['bun-windows-x64', 'bun-darwin-arm64', 'bun-linux-x64'],
|
||||
}
|
||||
class CleanError extends Data.TaggedError('CleanError')<{
|
||||
readonly dir: string
|
||||
readonly cause: unknown
|
||||
}> {}
|
||||
|
||||
class BuildError extends Data.TaggedError('BuildError')<{
|
||||
readonly target: BunTarget
|
||||
readonly cause: unknown
|
||||
}> {}
|
||||
|
||||
class ConfigError extends Data.TaggedError('ConfigError')<{
|
||||
readonly message: string
|
||||
readonly cause: unknown
|
||||
}> {}
|
||||
|
||||
// ============================================================================
|
||||
// Effects
|
||||
// Services
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 清理输出目录
|
||||
* 配置服务
|
||||
*/
|
||||
const cleanOutputDir = (dir: string) =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
await $`rm -rf ${dir}`
|
||||
return undefined
|
||||
},
|
||||
catch: (error: unknown) => ({
|
||||
_tag: 'CleanError' as const,
|
||||
message: `无法清理目录 ${dir}`,
|
||||
cause: error,
|
||||
class BuildConfigService extends Context.Tag('BuildConfigService')<
|
||||
BuildConfigService,
|
||||
BuildConfig
|
||||
>() {
|
||||
/**
|
||||
* 从原始数据创建并验证配置
|
||||
*/
|
||||
static fromRaw = (raw: unknown) =>
|
||||
Effect.gen(function* () {
|
||||
const decoded = yield* Schema.decodeUnknown(BuildConfigSchema)(raw)
|
||||
return decoded
|
||||
}).pipe(
|
||||
Effect.catchAll((error) =>
|
||||
Effect.fail(
|
||||
new ConfigError({
|
||||
message: '配置验证失败',
|
||||
cause: error,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* 默认配置 Layer
|
||||
*/
|
||||
static readonly Live = Layer.effect(
|
||||
BuildConfigService,
|
||||
BuildConfigService.fromRaw({
|
||||
entrypoint: './.output/server/index.mjs',
|
||||
outputDir: './out',
|
||||
targets: ['bun-windows-x64', 'bun-darwin-arm64', 'bun-linux-x64'],
|
||||
}),
|
||||
}).pipe(Effect.tap(() => Console.log(`✓ 已清理输出目录: ${dir}`)))
|
||||
|
||||
/**
|
||||
* 为单个目标平台构建
|
||||
*/
|
||||
const buildForTarget = (
|
||||
config: BuildConfig,
|
||||
target: BunTarget,
|
||||
): Effect.Effect<
|
||||
BuildResult,
|
||||
{ _tag: 'BuildError'; target: BunTarget; message: string; cause: unknown }
|
||||
> =>
|
||||
Effect.gen(function* () {
|
||||
yield* Console.log(`🔨 开始构建: ${target}`)
|
||||
|
||||
const output = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
Bun.build({
|
||||
entrypoints: [config.entrypoint],
|
||||
compile: {
|
||||
outfile: `server-${targetMap[target]}`,
|
||||
target: target,
|
||||
},
|
||||
outdir: config.outputDir,
|
||||
}),
|
||||
catch: (error: unknown) => ({
|
||||
_tag: 'BuildError' as const,
|
||||
target,
|
||||
message: `构建失败: ${target}`,
|
||||
cause: error,
|
||||
}),
|
||||
})
|
||||
|
||||
const paths = output.outputs.map((item: { path: string }) => item.path)
|
||||
|
||||
return {
|
||||
target,
|
||||
outputs: paths,
|
||||
} satisfies BuildResult
|
||||
})
|
||||
|
||||
/**
|
||||
* 并行构建所有目标平台
|
||||
*/
|
||||
const buildAllTargets = (config: BuildConfig) => {
|
||||
const effects = config.targets.map((target) => buildForTarget(config, target))
|
||||
return Effect.all(effects, { concurrency: 'unbounded' })
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出构建结果摘要
|
||||
* 文件系统服务
|
||||
*/
|
||||
const printBuildSummary = (results: ReadonlyArray<BuildResult>) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Console.log('\n📦 构建完成:')
|
||||
for (const result of results) {
|
||||
yield* Console.log(` ${result.target}:`)
|
||||
for (const path of result.outputs) {
|
||||
yield* Console.log(` - ${path}`)
|
||||
}
|
||||
}
|
||||
class FileSystemService extends Context.Tag('FileSystemService')<
|
||||
FileSystemService,
|
||||
{
|
||||
readonly cleanDir: (dir: string) => Effect.Effect<void, CleanError>
|
||||
}
|
||||
>() {
|
||||
static readonly Live = Layer.succeed(FileSystemService, {
|
||||
cleanDir: (dir: string) =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
await $`rm -rf ${dir}`
|
||||
},
|
||||
catch: (cause: unknown) =>
|
||||
new CleanError({
|
||||
dir,
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建服务
|
||||
*/
|
||||
class BuildService extends Context.Tag('BuildService')<
|
||||
BuildService,
|
||||
{
|
||||
readonly buildForTarget: (
|
||||
config: BuildConfig,
|
||||
target: BunTarget,
|
||||
) => Effect.Effect<BuildResult, BuildError>
|
||||
readonly buildAll: (
|
||||
config: BuildConfig,
|
||||
) => Effect.Effect<ReadonlyArray<BuildResult>, BuildError>
|
||||
}
|
||||
>() {
|
||||
static readonly Live = Layer.succeed(BuildService, {
|
||||
buildForTarget: (config: BuildConfig, target: BunTarget) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Console.log(`🔨 开始构建: ${target}`)
|
||||
|
||||
const output = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
Bun.build({
|
||||
entrypoints: [config.entrypoint],
|
||||
compile: {
|
||||
outfile: `server-${targetMap[target]}`,
|
||||
target: target,
|
||||
},
|
||||
outdir: config.outputDir,
|
||||
}),
|
||||
catch: (cause: unknown) =>
|
||||
new BuildError({
|
||||
target,
|
||||
cause,
|
||||
}),
|
||||
})
|
||||
|
||||
const paths = output.outputs.map((item: { path: string }) => item.path)
|
||||
|
||||
return {
|
||||
target,
|
||||
outputs: paths,
|
||||
} satisfies BuildResult
|
||||
}),
|
||||
|
||||
buildAll: (config: BuildConfig) =>
|
||||
Effect.gen(function* () {
|
||||
const effects = config.targets.map((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Console.log(`🔨 开始构建: ${target}`)
|
||||
|
||||
const output = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
Bun.build({
|
||||
entrypoints: [config.entrypoint],
|
||||
compile: {
|
||||
outfile: `server-${targetMap[target]}`,
|
||||
target: target,
|
||||
},
|
||||
outdir: config.outputDir,
|
||||
}),
|
||||
catch: (cause: unknown) =>
|
||||
new BuildError({
|
||||
target,
|
||||
cause,
|
||||
}),
|
||||
})
|
||||
|
||||
const paths = output.outputs.map(
|
||||
(item: { path: string }) => item.path,
|
||||
)
|
||||
|
||||
return {
|
||||
target,
|
||||
outputs: paths,
|
||||
} satisfies BuildResult
|
||||
}),
|
||||
)
|
||||
return yield* Effect.all(effects, { concurrency: 'unbounded' })
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告服务
|
||||
*/
|
||||
class ReporterService extends Context.Tag('ReporterService')<
|
||||
ReporterService,
|
||||
{
|
||||
readonly printSummary: (
|
||||
results: ReadonlyArray<BuildResult>,
|
||||
) => Effect.Effect<void>
|
||||
}
|
||||
>() {
|
||||
static readonly Live = Layer.succeed(ReporterService, {
|
||||
printSummary: (results: ReadonlyArray<BuildResult>) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Console.log('\n📦 构建完成:')
|
||||
for (const result of results) {
|
||||
yield* Console.log(` ${result.target}:`)
|
||||
for (const path of result.outputs) {
|
||||
yield* Console.log(` - ${path}`)
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Program
|
||||
// ============================================================================
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const config = defaultConfig
|
||||
const config = yield* BuildConfigService
|
||||
const fs = yield* FileSystemService
|
||||
const builder = yield* BuildService
|
||||
const reporter = yield* ReporterService
|
||||
|
||||
// 1. 清理输出目录
|
||||
yield* cleanOutputDir(config.outputDir)
|
||||
yield* fs.cleanDir(config.outputDir)
|
||||
yield* Console.log(`✓ 已清理输出目录: ${config.outputDir}`)
|
||||
|
||||
// 2. 并行构建所有目标
|
||||
const results = yield* buildAllTargets(config)
|
||||
const results = yield* builder.buildAll(config)
|
||||
|
||||
// 3. 输出构建摘要
|
||||
yield* printBuildSummary(results)
|
||||
yield* reporter.printSummary(results)
|
||||
|
||||
return results
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Layer Composition
|
||||
// ============================================================================
|
||||
|
||||
const MainLayer = Layer.mergeAll(
|
||||
BuildConfigService.Live,
|
||||
FileSystemService.Live,
|
||||
BuildService.Live,
|
||||
ReporterService.Live,
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Runner
|
||||
// ============================================================================
|
||||
|
||||
const main = program.pipe(
|
||||
Effect.catchAllCause((cause) =>
|
||||
Console.error('❌ 构建失败:', cause).pipe(
|
||||
Effect.flatMap(() => Effect.fail(cause)),
|
||||
),
|
||||
),
|
||||
const runnable = program.pipe(
|
||||
Effect.provide(MainLayer),
|
||||
Effect.catchTags({
|
||||
CleanError: (error) =>
|
||||
Console.error(`❌ 清理目录失败: ${error.dir}`, error.cause),
|
||||
BuildError: (error) =>
|
||||
Console.error(`❌ 构建失败 [${error.target}]:`, error.cause),
|
||||
ConfigError: (error) =>
|
||||
Console.error(`❌ 配置错误: ${error.message}`, error.cause),
|
||||
}),
|
||||
Effect.tapErrorCause((cause) => Console.error('❌ 未预期的错误:', cause)),
|
||||
)
|
||||
|
||||
Effect.runPromise(main).catch(() => {
|
||||
Effect.runPromise(runnable).catch(() => {
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user