| 1 | import * as Deferred from "effect/Deferred"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Path from "effect/Path"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 7 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 8 | import * as LocalProvider from "../Local/LocalProvider.ts"; |
| 9 | import { Stack } from "../Stack.ts"; |
| 10 | import { Stage } from "../Stage.ts"; |
| 11 | import { moduleExtension } from "../Util/Node.ts"; |
| 12 | import { sha256 } from "../Util/sha256.ts"; |
| 13 | import { Function } from "./Function.ts"; |
| 14 | import { |
| 15 | buildFunctionArtifact, |
| 16 | validateFunctionZip, |
| 17 | } from "./FunctionArtifact.ts"; |
| 18 | import { |
| 19 | FunctionConfigurationError, |
| 20 | functionEnvironment, |
| 21 | functionSlug, |
| 22 | } from "./FunctionConfig.ts"; |
| 23 | |
| 24 | export const LocalFunctionProvider = () => |
| 25 | LocalProvider.make( |
| 26 | Function, |
| 27 | import.meta.resolve(`./LocalFunction${moduleExtension(import.meta.url)}`), |
| 28 | Effect.gen(function* () { |
| 29 | const fs = yield* FileSystem.FileSystem; |
| 30 | const path = yield* Path.Path; |
| 31 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 32 | return { |
| 33 | resolveConfig: Effect.fn(function* ({ news, bindings }) { |
| 34 | const source = news.main |
| 35 | ? news.main.startsWith("file:") |
| 36 | ? yield* path.fromFileUrl(new URL(news.main)) |
| 37 | : news.main |
| 38 | : news.artifact?.zip; |
| 39 | const hash = source |
| 40 | ? yield* fs.readFile(source).pipe(Effect.flatMap(sha256)) |
| 41 | : (yield* buildFunctionArtifact(news)).codeHash; |
| 42 | return { news, bindings, hash }; |
| 43 | }), |
| 44 | start: Effect.fn(function* ({ |
| 45 | id, |
| 46 | instanceId, |
| 47 | news, |
| 48 | bindings, |
| 49 | invalidate, |
| 50 | }) { |
| 51 | const command = news.dev?.command ?? "neon"; |
| 52 | const version = yield* spawner |
| 53 | .string(ChildProcess.make(command, ["--version"])) |
| 54 | .pipe( |
| 55 | Effect.timeout("5 seconds"), |
| 56 | Effect.mapError( |
| 57 | (cause) => |
| 58 | new FunctionConfigurationError({ |
| 59 | message: |
| 60 | cause._tag === "PlatformError" && |
| 61 | cause.reason._tag === "NotFound" |
| 62 | ? `Neon CLI executable '${command}' was not found. Install Neon CLI >=2.45.0: https://neon.com/cli . Ensure '${command}' is on PATH, or set Function.dev.command to its executable path. Local Functions also require Node 24.` |
| 63 | : `Could not run '${command} --version': ${cause.message}`, |
| 64 | cause, |
| 65 | }), |
| 66 | ), |
| 67 | ); |
| 68 | const match = version.match(/(\d+)\.(\d+)\.(\d+)/); |
| 69 | if ( |
| 70 | !match || |
| 71 | Number(match[1]) < 2 || |
| 72 | (Number(match[1]) === 2 && Number(match[2]) < 45) |
| 73 | ) |
| 74 | return yield* new FunctionConfigurationError({ |
| 75 | message: "Local Function WebSockets require Neon CLI >=2.45.0", |
| 76 | }); |
| 77 | const nodeVersion = yield* spawner.string( |
| 78 | ChildProcess.make("node", ["--version"]), |
| 79 | ); |
| 80 | if (!/^v24\./.test(nodeVersion.trim())) |
| 81 | return yield* new FunctionConfigurationError({ |
| 82 | message: "Local Functions require Node 24", |
| 83 | }); |
| 84 | const root = yield* fs.makeTempDirectoryScoped({ |
| 85 | prefix: "alchemy-neon-function-", |
| 86 | }); |
| 87 | const writeArtifact = Effect.gen(function* () { |
| 88 | const artifact = yield* buildFunctionArtifact(news); |
| 89 | const files = yield* validateFunctionZip(artifact.archive); |
| 90 | for (const [name, content] of Object.entries(files)) { |
| 91 | if (name.endsWith("/")) continue; |
| 92 | const file = path.join(root, name); |
| 93 | yield* fs.makeDirectory(path.dirname(file), { recursive: true }); |
| 94 | yield* fs.writeFile(file, content); |
| 95 | } |
| 96 | return artifact.codeHash; |
| 97 | }); |
| 98 | const codeHash = yield* writeArtifact; |
| 99 | const slug = yield* functionSlug(id, news.slug); |
| 100 | const env = yield* functionEnvironment(news, bindings); |
| 101 | const stack = yield* Stack; |
| 102 | const stage = yield* Stage; |
| 103 | const base = yield* Effect.sync(() => |
| 104 | Object.fromEntries( |
| 105 | ["PATH", "HOME", "TMPDIR", "SystemRoot"].flatMap((key) => |
| 106 | process.env[key] ? [[key, process.env[key]!]] : [], |
| 107 | ), |
| 108 | ), |
| 109 | ); |
| 110 | const child = yield* spawner.spawn( |
| 111 | ChildProcess.make( |
| 112 | command, |
| 113 | [ |
| 114 | "dev", |
| 115 | "--source", |
| 116 | path.join(root, "index.mjs"), |
| 117 | ...(news.dev?.port ? ["--port", String(news.dev.port)] : []), |
| 118 | ], |
| 119 | { |
| 120 | cwd: root, |
| 121 | env: { |
| 122 | ...base, |
| 123 | ...env, |
| 124 | ALCHEMY_STACK_NAME: stack.name, |
| 125 | ALCHEMY_STAGE: stage, |
| 126 | ALCHEMY_PHASE: "runtime", |
| 127 | }, |
| 128 | extendEnv: false, |
| 129 | stdin: "ignore", |
| 130 | stdout: "pipe", |
| 131 | stderr: "pipe", |
| 132 | }, |
| 133 | ), |
| 134 | ); |
| 135 | const ready = yield* Deferred.make<string>(); |
| 136 | const capture = ( |
| 137 | stream: Stream.Stream< |
| 138 | Uint8Array, |
| 139 | import("effect/PlatformError").PlatformError |
| 140 | >, |
| 141 | ) => |
| 142 | stream.pipe( |
| 143 | Stream.decodeText, |
| 144 | Stream.runForEach((text) => { |
| 145 | const url = text.match( |
| 146 | /http:\/\/(?:localhost|127\.0\.0\.1):\d+/, |
| 147 | )?.[0]; |
| 148 | return url ? Deferred.succeed(ready, url) : Effect.void; |
| 149 | }), |
| 150 | Effect.forkScoped, |
| 151 | ); |
| 152 | yield* capture(child.stdout); |
| 153 | yield* capture(child.stderr); |
| 154 | const url = yield* Effect.raceAllFirst([ |
| 155 | Deferred.await(ready).pipe(Effect.timeout("15 seconds")), |
| 156 | child.exitCode.pipe( |
| 157 | Effect.flatMap( |
| 158 | () => |
| 159 | new FunctionConfigurationError({ |
| 160 | message: |
| 161 | "Neon local runtime exited before readiness; install Neon CLI >=2.45.0 and Node 24", |
| 162 | }), |
| 163 | ), |
| 164 | ), |
| 165 | ]); |
| 166 | yield* child.exitCode.pipe( |
| 167 | Effect.exit, |
| 168 | Effect.andThen(invalidate), |
| 169 | Effect.forkScoped, |
| 170 | ); |
| 171 | const watched = |
| 172 | news.main ?? news.artifact?.directory ?? news.artifact?.zip; |
| 173 | if (watched) { |
| 174 | const source = watched.startsWith("file:") |
| 175 | ? yield* path.fromFileUrl(new URL(watched)) |
| 176 | : path.resolve(watched); |
| 177 | const directory = news.artifact?.directory |
| 178 | ? source |
| 179 | : path.dirname(source); |
| 180 | yield* fs.watch(directory, { recursive: true }).pipe( |
| 181 | Stream.runForEach(() => |
| 182 | writeArtifact.pipe( |
| 183 | Effect.ignoreCause({ |
| 184 | log: "Error", |
| 185 | message: "Neon Function rebuild failed", |
| 186 | }), |
| 187 | ), |
| 188 | ), |
| 189 | Effect.forkScoped, |
| 190 | ); |
| 191 | } |
| 192 | const projectId = |
| 193 | news.branch?.projectId ?? news.project?.projectId ?? "local"; |
| 194 | const branchId = news.branch?.branchId ?? "local"; |
| 195 | return { |
| 196 | projectId, |
| 197 | branchId, |
| 198 | functionId: `dev:${instanceId}`, |
| 199 | slug, |
| 200 | name: news.name ?? slug, |
| 201 | url, |
| 202 | currentDeploymentId: undefined, |
| 203 | activeDeploymentId: undefined, |
| 204 | status: "local", |
| 205 | codeHash, |
| 206 | environment: Object.keys(env), |
| 207 | }; |
| 208 | }), |
| 209 | }; |
| 210 | }), |
| 211 | ); |