1import * as Deferred from "effect/Deferred";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import * as Stream from "effect/Stream";
6import * as ChildProcess from "effect/process/ChildProcess";
7import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
8import * as LocalProvider from "../Local/LocalProvider.ts";
9import { Stack } from "../Stack.ts";
10import { Stage } from "../Stage.ts";
11import { moduleExtension } from "../Util/Node.ts";
12import { sha256 } from "../Util/sha256.ts";
13import { Function } from "./Function.ts";
14import {
15 buildFunctionArtifact,
16 validateFunctionZip,
17} from "./FunctionArtifact.ts";
18import {
19 FunctionConfigurationError,
20 functionEnvironment,
21 functionSlug,
22} from "./FunctionConfig.ts";
23
24export 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 );