Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/alchemy/src/Bundle/PurePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,21 @@ export function packageNameFromId(id: string): string | null {
return parts[0];
}

/**
* Returns the directory of the `node_modules/<pkg>` root that owns `id`,
* or `null` when the id has no `node_modules/` segment. Preserves the
* id's original separators so it compares cleanly against
* `path.dirname` results.
*/
function packageRootFromId(id: string): string | null {
const name = packageNameFromId(id);
if (name === null) return null;
const normalized = id.replace(/\\/g, "/");
const idx = normalized.lastIndexOf("/node_modules/");
// `replace` is char-for-char, so indices line up with the original id.
return id.slice(0, idx + "/node_modules/".length + name.length);
}

/**
* Resolves the owning {@link PackageInfo} for a module id by walking up
* the directory tree to the nearest `package.json`. This is what makes
Expand All @@ -303,6 +318,12 @@ export async function resolvePackageInfo(
// the file is unreadable (or this is a virtual id from a test) we fall
// back to a minimal record using the path-derived name.
const fastName = packageNameFromId(cleanId);
// For `node_modules/<pkg>/...` ids, never walk above the package's own
// root — any `package.json` above `node_modules` belongs to the
// consumer (or, on Windows, a stray drive-root manifest), not to this
// module's package.
const stopDir = packageRootFromId(cleanId);
const normalizedStop = stopDir?.replace(/\\/g, "/") ?? null;

let dir = path.dirname(cleanId);
// Remember dirs we visited for this lookup so we can backfill their
Expand Down Expand Up @@ -343,6 +364,9 @@ export async function resolvePackageInfo(
foundInfo = info;
break;
}
if (normalizedStop !== null && dir.replace(/\\/g, "/") === normalizedStop) {
break;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
Expand Down
8 changes: 8 additions & 0 deletions packages/alchemy/src/Cloudflare/Auth/AuthProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,14 @@ export const DEFAULT_SCOPES = [
"connectivity:admin",
"containers:write",
"d1:write",
// DnsRecord + Tunnel/Gateway (Zero Trust) resources are managed by
// alchemy; without these a default oauth login fails their API calls
// with Unauthorized. (Zone *create* has no oauth scope at all — only
// an API token can grant it.)
"dns_records:read",
"dns_records:edit",
"teams:read",
"teams:write",
"pages:write",
"pipelines:write",
"queues:write",
Expand Down
11 changes: 9 additions & 2 deletions packages/alchemy/src/Cloudflare/Gateway/Rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,10 @@ export const GatewayRuleProvider = () =>
Provider.effect(
GatewayRule,
Effect.gen(function* () {
const { accountId } = yield* yield* CloudflareEnvironment;
// Resolve credentials lazily inside lifecycle operations — resolving
// at layer-build time would force auth (or an interactive login)
// just to construct the provider collection.
const environment = yield* CloudflareEnvironment;

const createRule = yield* zeroTrust.createGatewayRule;
const getRule = yield* zeroTrust.getGatewayRule;
Expand All @@ -225,7 +228,9 @@ export const GatewayRuleProvider = () =>
// Locate an existing rule by name when no ruleId is cached — used for
// adoption and as a recovery path after a create returns a conflict.
const findRuleByName = (name: string) =>
listRules.items({ accountId }).pipe(
environment.pipe(
Effect.map(({ accountId }) => listRules.items({ accountId })),
Stream.unwrap,
Stream.runCollect,
Effect.map((chunk) =>
Array.from(chunk).find(
Expand All @@ -241,6 +246,7 @@ export const GatewayRuleProvider = () =>

const observeById = (ruleId: string) =>
Effect.gen(function* () {
const { accountId } = yield* environment;
const r = yield* getRule({ accountId, ruleId }).pipe(
// Distilled tags transport errors but not the live Cloudflare 404
// for a missing rule. Swallow generically so the reconcile flow
Expand All @@ -266,6 +272,7 @@ export const GatewayRuleProvider = () =>
}),

reconcile: Effect.fn(function* ({ id, news, output }) {
const { accountId } = yield* environment;
const resolvedName = yield* resolveName(id, news.name);
const body = buildMutableBody(news, resolvedName);

Expand Down
7 changes: 6 additions & 1 deletion packages/alchemy/src/Cloudflare/Tunnel/Configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,13 +278,17 @@ export const TunnelConfigurationProvider = () =>
Provider.effect(
TunnelConfiguration,
Effect.gen(function* () {
const { accountId } = yield* yield* CloudflareEnvironment;
// Resolve credentials lazily inside lifecycle operations — resolving
// at layer-build time would force auth (or an interactive login)
// just to construct the provider collection.
const environment = yield* CloudflareEnvironment;

const getConfig = yield* zeroTrust.getTunnelCloudflaredConfiguration;
const putConfig = yield* zeroTrust.putTunnelCloudflaredConfiguration;

const observe = (tunnelId: string) =>
Effect.gen(function* () {
const { accountId } = yield* environment;
const r = yield* getConfig({ accountId, tunnelId }).pipe(
Effect.catch((err) =>
isConfigNotFound(err)
Expand Down Expand Up @@ -315,6 +319,7 @@ export const TunnelConfigurationProvider = () =>
}),

reconcile: Effect.fn(function* ({ news }) {
const { accountId } = yield* environment;
// Inputs have been resolved to concrete strings by the Plan layer.
const tunnelId = news.tunnelId as string;
const catchAllService =
Expand Down
14 changes: 9 additions & 5 deletions packages/alchemy/src/Drizzle/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,11 +262,15 @@ export const SchemaProvider = () =>
// otherwise downstream resources (e.g. Neon.Branch) would see
// `schema.out` as an unresolved Output during plan and cascade
// into spurious updates of their own.
const { sqlStatements } = yield* detectDrift(news);
// Originally `output.out` was an absolute path, which is not portable.
// So, we trigger an update to migrate existing resources.
// This is safe because `regenerate` is idempotent.
return sqlStatements.length > 0 || path.isAbsolute(output.out)
const { out, sqlStatements } = yield* detectDrift(news);
// Originally `output.out` was an absolute path, which is not
// portable, so flag legacy states for one migrating update (safe
// because `regenerate` is idempotent). Compare against today's
// canonical form rather than `isAbsolute` — on Windows an `out`
// on a different drive than cwd has no relative form, and
// treating its absolute representation as "legacy" would force
// an update on every deploy.
return sqlStatements.length > 0 || output.out !== relativeOut(out)
? { action: "update" }
: undefined;
}),
Expand Down
8 changes: 6 additions & 2 deletions packages/alchemy/test/AWS/Lambda/Function.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import * as Effect from "effect/Effect";
import * as Schedule from "effect/Schedule";
import * as HttpClient from "effect/unstable/http/HttpClient";
import { TestFunction, TestFunctionLive } from "./handler.ts";
import { fileURLToPath } from "node:url";

const timeoutHandlerPath = new URL("./timeout-handler.ts", import.meta.url)
.pathname;
// `URL.pathname` yields `/D:/...` on Windows; fileURLToPath gives a real
// filesystem path on every platform.
const timeoutHandlerPath = fileURLToPath(
new URL("./timeout-handler.ts", import.meta.url),
);
Comment on lines +14 to +16

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pathe.resolve(import.meta.dirname, "timeout-handler.ts")?


const { test } = Test.make({ providers: AWS.providers() });

Expand Down
4 changes: 3 additions & 1 deletion packages/alchemy/test/Build/Build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ test.provider(
}),
);

expect(build1.outdir).toBe(distDir);
// `outdir` is resolved with the platform's separators; normalize so
// the comparison holds on Windows too.
expect(pathe.normalize(build1.outdir)).toBe(distDir);
expect(build1.hash).toBeDefined();
expect(typeof build1.hash).toBe("string");
expect(build1.hash.length).toBeGreaterThan(0);
Expand Down
10 changes: 9 additions & 1 deletion packages/alchemy/test/Bundle/PurePlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,9 +472,17 @@ describe("Bundle.build with purePlugin", () => {
);

// Symlink the package under node_modules so rolldown can find it.
// Windows requires elevation for "dir" symlinks; junctions work
// unprivileged and the type argument is ignored on POSIX.
const nm = path.join(root, "node_modules");
yield* fs.makeDirectory(nm, { recursive: true });
nodeFs.symlinkSync(pkgDir, path.join(nm, "fake-ws"), "dir");
yield* Effect.sync(() =>
nodeFs.symlinkSync(
pkgDir,
path.join(nm, "fake-ws"),
process.platform === "win32" ? "junction" : "dir",
),
);

const entry = path.join(root, "entry.ts");
yield* fs.writeFileString(
Expand Down
8 changes: 6 additions & 2 deletions packages/alchemy/test/Local/RpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ import * as Stream from "effect/Stream";
import * as ChildProcess from "effect/unstable/process/ChildProcess";
import { openWebSocket, waitForExit } from "./fixtures/process-effect.ts";
import { runtimes } from "./fixtures/runtimes.ts";
import { fileURLToPath } from "node:url";

const FIXTURE_TS = new URL("./fixtures/rpc-server-entry.ts", import.meta.url)
.pathname;
// `URL.pathname` yields `/D:/...` on Windows; fileURLToPath gives a real
// filesystem path on every platform.
const FIXTURE_TS = fileURLToPath(
new URL("./fixtures/rpc-server-entry.ts", import.meta.url),
);

const ADDRESS_RE = /<ALCHEMY_RPC_ADDRESS>(.+?)<\/ALCHEMY_RPC_ADDRESS>/;

Expand Down
35 changes: 25 additions & 10 deletions packages/alchemy/test/Local/fixtures/process-effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,23 +57,38 @@ export const isAlive = (pid: number): Effect.Effect<boolean> =>
});

/**
* Resolves the pid currently LISTENing on the port of `wsUrl`. Uses an
* `lsof` invocation; we don't own a handle to whatever process is
* listening so there's no ChildProcessHandle equivalent.
* Resolves the pid currently LISTENing on the port of `wsUrl`. We don't
* own a handle to whatever process is listening so there's no
* ChildProcessHandle equivalent — shell out to `lsof` (POSIX) or
* `netstat -ano` (Windows, where lsof doesn't exist).
*/
export const pidListeningOn = (wsUrl: string) =>
ChildProcess.make(
"lsof",
["-iTCP:" + new URL(wsUrl).port, "-sTCP:LISTEN", "-t"],
{
export const pidListeningOn = (wsUrl: string) => {
const port = new URL(wsUrl).port;
if (process.platform === "win32") {
return ChildProcess.make("netstat", ["-ano", "-p", "TCP"], {
stdout: "pipe",
},
).pipe(
}).pipe(
Effect.flatMap((handle) =>
handle.stdout.pipe(Stream.decodeText, Stream.mkString),
),
Effect.map((stdout) => {
// e.g. ` TCP 127.0.0.1:58828 0.0.0.0:0 LISTENING 12345`
const line = stdout
.split(/\r?\n/)
.find((l) => l.includes(`:${port} `) && l.includes("LISTENING"));
return Number.parseInt(line?.trim().split(/\s+/).at(-1) ?? "", 10);
}),
);
}
return ChildProcess.make("lsof", ["-iTCP:" + port, "-sTCP:LISTEN", "-t"], {
stdout: "pipe",
}).pipe(
Effect.flatMap((handle) =>
handle.stdout.pipe(Stream.decodeText, Stream.mkString),
),
Effect.map((stdout) => Number.parseInt(stdout.trim().split("\n")[0]!, 10)),
);
};

/** Send a signal to a pid we don't own a handle to. */
export const killPid = (
Expand Down
Loading