Refactor local emulator configuration management and remove obsolete file bridge

- Removed the `readConfigFileContentIfExists` and `writeConfigToFile` functions from the local emulator module, simplifying file handling.
- Updated the `readConfigFromFile` function to utilize environment variables for configuration content, enhancing flexibility.
- Refactored tests to align with the new configuration management approach, ensuring accurate validation of file reading and handling.
- Consolidated Docker environment variables into a new `base.env` file for improved organization and maintainability.
- Removed the host file bridge script and related logic, streamlining the emulator's architecture and reducing complexity.

These changes significantly enhance the local emulator's usability and maintainability for developers.
This commit is contained in:
mantrakp04 2026-03-18 15:33:44 -07:00
parent 00106429ad
commit c2e4698dfc
15 changed files with 167 additions and 718 deletions

View File

@ -4,9 +4,7 @@ import {
LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE,
LOCAL_EMULATOR_OWNER_TEAM_ID,
isLocalEmulatorEnabled,
readConfigFileContentIfExists,
readConfigFromFile,
writeConfigToFile,
} from "@/lib/local-emulator";
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
@ -193,11 +191,6 @@ export const POST = createSmartRouteHandler({
const absoluteFilePath = path.resolve(req.body.absolute_file_path);
const fileContent = await readConfigFileContentIfExists(absoluteFilePath) ?? "";
if (fileContent.trim() === "") {
await writeConfigToFile(absoluteFilePath, {});
}
await assertLocalEmulatorOwnerTeamReadiness();
const projectId = await getOrCreateLocalEmulatorProjectId(absoluteFilePath);

View File

@ -11,7 +11,7 @@ import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { deindent, stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
import * as yup from "yup";
import { RawQuery, globalPrismaClient, rawQuery } from "../prisma-client";
import { LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE, getLocalEmulatorFilePath, isLocalEmulatorEnabled, isLocalEmulatorProject, readConfigFromFile, writeConfigToFile } from "./local-emulator";
import { LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE, isLocalEmulatorEnabled, isLocalEmulatorProject, readConfigFromFile } from "./local-emulator";
import { listPermissionDefinitionsFromConfig } from "./permissions";
type BranchConfigSourceApi = yup.InferType<typeof branchConfigSourceSchema>;
@ -266,14 +266,6 @@ export async function setBranchConfigOverride(options: {
}): Promise<void> {
const newConfig = migrateConfigOverride("branch", options.branchConfigOverride);
if (isLocalEmulatorEnabled() && await isLocalEmulatorProject(options.projectId)) {
const filePath = await getLocalEmulatorFilePath(options.projectId);
if (filePath) {
await writeConfigToFile(filePath, newConfig);
return;
}
}
// large configs make our DB slow; let's prevent them early
const newConfigString = JSON.stringify(newConfig);
if (newConfigString.length > 1_000_000) {

View File

@ -1,91 +1,33 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { readConfigFileContentIfExists, readConfigFromFile, writeConfigToFile } from "./local-emulator";
import { readConfigFromFile } from "./local-emulator";
describe("local emulator file bridge", () => {
describe("local emulator config", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("reads config files through the host file bridge when configured", async () => {
vi.stubEnv("STACK_LOCAL_EMULATOR_FILE_BRIDGE_URL", "http://127.0.0.1:8116");
vi.stubEnv("STACK_LOCAL_EMULATOR_FILE_BRIDGE_TOKEN", "bridge-token");
it("reads config from STACK_LOCAL_EMULATOR_CONFIG_CONTENT env var when set", async () => {
const content = `export const config = { auth: { allowLocalhost: true } };\n`;
vi.stubEnv("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", Buffer.from(content).toString("base64"));
const fetchMock = vi.fn(async () => {
return new Response(JSON.stringify({
exists: true,
content: "export const config = { auth: { allowLocalhost: true } };\n",
}), {
status: 200,
headers: {
"Content-Type": "application/json",
},
});
});
vi.stubGlobal("fetch", fetchMock);
await expect(readConfigFromFile("/Users/tester/example/stack.config.ts")).resolves.toMatchInlineSnapshot(`
await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).resolves.toMatchInlineSnapshot(`
{
"auth": {
"allowLocalhost": true,
},
}
`);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
new URL("/read", "http://127.0.0.1:8116"),
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Content-Type": "application/json",
"X-Stack-Emulator-Token": "bridge-token",
}),
}),
);
});
it("returns null for missing files when the host file bridge reports they do not exist", async () => {
vi.stubEnv("STACK_LOCAL_EMULATOR_FILE_BRIDGE_URL", "http://127.0.0.1:8116");
vi.stubEnv("STACK_LOCAL_EMULATOR_FILE_BRIDGE_TOKEN", "bridge-token");
vi.stubGlobal("fetch", vi.fn(async () => {
return new Response(JSON.stringify({ exists: false }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
});
}));
await expect(readConfigFileContentIfExists("/Users/tester/example/missing-stack.config.ts")).resolves.toBeNull();
it("returns empty object when env var is not set and file does not exist", async () => {
await expect(readConfigFromFile("/nonexistent/stack.config.ts")).resolves.toEqual({});
});
it("writes config files through the host file bridge when configured", async () => {
vi.stubEnv("STACK_LOCAL_EMULATOR_FILE_BRIDGE_URL", "http://127.0.0.1:8116");
vi.stubEnv("STACK_LOCAL_EMULATOR_FILE_BRIDGE_TOKEN", "bridge-token");
it("returns empty object when env var content is empty", async () => {
const content = ``;
vi.stubEnv("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", Buffer.from(content).toString("base64"));
const fetchMock = vi.fn(async () => {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
});
});
vi.stubGlobal("fetch", fetchMock);
await writeConfigToFile("/Users/tester/example/stack.config.ts", { teams: { enabled: true } });
expect(fetchMock).toHaveBeenCalledWith(
new URL("/write", "http://127.0.0.1:8116"),
expect.objectContaining({
method: "POST",
body: JSON.stringify({
path: "/Users/tester/example/stack.config.ts",
content: `export const config = ${JSON.stringify({ teams: { enabled: true } }, null, 2)};\n`,
}),
}),
);
await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).resolves.toEqual({});
});
});

View File

@ -3,7 +3,7 @@ import path from "path";
import { createJiti } from "jiti";
import { isValidConfig } from "@stackframe/stack-shared/dist/config/format";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { globalPrismaClient } from "@/prisma-client";
export const LOCAL_EMULATOR_ADMIN_USER_ID = "63abbc96-5329-454a-ba56-e0460173c6c1";
@ -36,102 +36,17 @@ export async function isLocalEmulatorProject(projectId: string) {
return project !== null;
}
export async function getLocalEmulatorFilePath(projectId: string): Promise<string | null> {
const result = await globalPrismaClient.localEmulatorProject.findUnique({
where: { projectId },
select: { absoluteFilePath: true },
});
return result?.absoluteFilePath ?? null;
}
function getLocalEmulatorFileBridgeConfig() {
const url = getEnvVariable("STACK_LOCAL_EMULATOR_FILE_BRIDGE_URL", "");
const token = getEnvVariable("STACK_LOCAL_EMULATOR_FILE_BRIDGE_TOKEN", "");
if (url === "") {
return null;
}
if (token === "") {
throw new StackAssertionError("STACK_LOCAL_EMULATOR_FILE_BRIDGE_TOKEN must be set when STACK_LOCAL_EMULATOR_FILE_BRIDGE_URL is configured.");
}
return { url, token };
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
async function requestLocalEmulatorFileBridge(pathname: string, body: Record<string, unknown>): Promise<unknown> {
const bridgeConfig = getLocalEmulatorFileBridgeConfig();
if (bridgeConfig === null) {
throw new StackAssertionError("Local emulator file bridge is not configured.");
}
const response = await fetch(new URL(pathname, bridgeConfig.url), {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Stack-Emulator-Token": bridgeConfig.token,
},
body: JSON.stringify(body),
});
const responseText = await response.text();
if (!response.ok) {
throw new StackAssertionError(`Local emulator file bridge request failed: ${response.status} ${responseText || response.statusText}`);
}
try {
return JSON.parse(responseText) as unknown;
} catch {
throw new StackAssertionError(`Local emulator file bridge returned invalid JSON for ${pathname}.`);
}
}
export async function readConfigFileContentIfExists(filePath: string): Promise<string | null> {
const bridgeConfig = getLocalEmulatorFileBridgeConfig();
if (bridgeConfig !== null) {
const responseJson = await requestLocalEmulatorFileBridge("/read", { path: filePath });
if (!isObject(responseJson) || typeof responseJson.exists !== "boolean") {
throw new StackAssertionError("Local emulator file bridge returned an invalid read response.", { responseJson });
}
if (!responseJson.exists) {
return null;
}
if (typeof responseJson.content !== "string") {
throw new StackAssertionError("Local emulator file bridge read response is missing file content.", { responseJson });
}
return responseJson.content;
}
try {
return await fs.readFile(filePath, "utf-8");
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error;
}
}
async function writeConfigFileContent(filePath: string, content: string): Promise<void> {
const bridgeConfig = getLocalEmulatorFileBridgeConfig();
if (bridgeConfig !== null) {
const responseJson = await requestLocalEmulatorFileBridge("/write", { path: filePath, content });
if (!isObject(responseJson) || responseJson.ok !== true) {
throw new StackAssertionError("Local emulator file bridge returned an invalid write response.", { responseJson });
}
return;
}
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, content, "utf-8");
}
export async function readConfigFromFile(filePath: string): Promise<Record<string, unknown>> {
const content = await readConfigFileContentIfExists(filePath);
if (content === null) {
throw new StatusError(StatusError.BadRequest, `Config file not found: ${filePath}`);
const configContentBase64 = getEnvVariable("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", "");
const content = configContentBase64 !== ""
? Buffer.from(configContentBase64, "base64").toString("utf-8")
: await fs.readFile(filePath, "utf-8").catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
});
if (content === null || content.trim() === "") {
return {};
}
const jiti = createJiti(import.meta.url, { cache: false });
@ -142,8 +57,3 @@ export async function readConfigFromFile(filePath: string): Promise<Record<strin
}
return config;
}
export async function writeConfigToFile(filePath: string, config: Record<string, unknown>): Promise<void> {
const content = `export const config = ${JSON.stringify(config, null, 2)};\n`;
await writeConfigFileContent(filePath, content);
}

View File

@ -0,0 +1,66 @@
NEXT_PUBLIC_STACK_DOCS_BASE_URL=https://docs.stack-auth.com
NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true
NEXT_PUBLIC_STACK_PROJECT_ID=internal
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
STACK_SECRET_SERVER_KEY=this-secret-server-key-is-for-local-development-only
STACK_SERVER_SECRET=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo
STACK_CHANGELOG_URL=https://raw.githubusercontent.com/stack-auth/stack-auth/refs/heads/dev/CHANGELOG.md
STACK_SEED_ENABLE_DUMMY_PROJECT=true
STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=true
STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED=true
STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST=true
STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS=github,spotify,google,microsoft
STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS=true
STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=this-secret-server-key-is-for-local-development-only
STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=this-super-secret-admin-key-is-for-local-development-only
STACK_SVIX_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NTUxNDA2MzksImV4cCI6MTk3MDUwMDYzOSwibmJmIjoxNjU1MTQwNjM5LCJpc3MiOiJzdml4LXNlcnZlciIsInN1YiI6Im9yZ18yM3JiOFlkR3FNVDBxSXpwZ0d3ZFhmSGlyTXUifQ.En8w77ZJWbd0qrMlHHupHUB-4cx17RfzFykseg95SUk
STACK_OPENAI_API_KEY=mock_openai_api_key
STACK_OPENROUTER_API_KEY=FORWARD_TO_PRODUCTION
STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey
STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret
STACK_RESEND_API_KEY=mock_resend_api_key
STACK_RESEND_WEBHOOK_SECRET=mock_resend_webhook_secret
STACK_DNSIMPLE_API_TOKEN=mock_dnsimple_api_token
STACK_DNSIMPLE_ACCOUNT_ID=mock_dnsimple_account_id
STACK_DNSIMPLE_API_BASE_URL=https://api.dnsimple.com/v2
STACK_FREESTYLE_API_KEY=mock_stack_freestyle_key
STACK_VERCEL_SANDBOX_TOKEN=vercel_sandbox_disabled_for_local_development
CRON_SECRET=mock_cron_secret
STACK_S3_REGION=us-east-1
STACK_S3_ACCESS_KEY_ID=s3mockroot
STACK_S3_SECRET_ACCESS_KEY=s3mockroot
STACK_S3_BUCKET=stack-storage
STACK_S3_PRIVATE_BUCKET=stack-storage-private
STACK_AWS_REGION=us-east-1
STACK_AWS_ACCESS_KEY_ID=test
STACK_AWS_SECRET_ACCESS_KEY=test
STACK_QSTASH_TOKEN=eyJVc2VySUQiOiJkZWZhdWx0VXNlciIsIlBhc3N3b3JkIjoiZGVmYXVsdFBhc3N3b3JkIn0=
STACK_QSTASH_CURRENT_SIGNING_KEY=sig_7kYjw48mhY7kAjqNGcy6cr29RJ6r
STACK_QSTASH_NEXT_SIGNING_KEY=sig_5ZB6DVzB1wjE8S6rZ7eenA8Pdnhs
STACK_CLICKHOUSE_ADMIN_USER=stackframe
STACK_CLICKHOUSE_ADMIN_PASSWORD=PASSWORD-PLACEHOLDER--9gKyMxJeMx
STACK_CLICKHOUSE_EXTERNAL_PASSWORD=PASSWORD-PLACEHOLDER--EZeHscBMzE
STACK_EMAIL_PORT=2500
STACK_EMAIL_SECURE=false
STACK_EMAIL_USERNAME=does-not-matter
STACK_EMAIL_PASSWORD=does-not-matter
STACK_EMAIL_SENDER=noreply@example.com
STACK_DEFAULT_EMAIL_CAPACITY_PER_HOUR=10000
STACK_EMAIL_MONITOR_PROJECT_ID=internal
STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN=stack-generated.example.com
STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY=this-is-a-fake-key
STACK_EMAIL_MONITOR_SECRET_TOKEN=this-secret-token-is-for-local-development-only
STACK_EMAIL_MONITOR_USE_INBUCKET=true
STACK_FEATUREBASE_JWT_SECRET=secret-value
STACK_FORWARD_MOCK_OAUTH_SERVER=false
STACK_GITHUB_CLIENT_ID=MOCK
STACK_GITHUB_CLIENT_SECRET=MOCK
STACK_GOOGLE_CLIENT_ID=MOCK
STACK_GOOGLE_CLIENT_SECRET=MOCK
STACK_MICROSOFT_CLIENT_ID=MOCK
STACK_MICROSOFT_CLIENT_SECRET=MOCK
STACK_SPOTIFY_CLIENT_ID=MOCK
STACK_SPOTIFY_CLIENT_SECRET=MOCK
STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS=true

View File

@ -46,87 +46,26 @@ services:
volumes:
- "${HOME}:${HOME}"
- "/tmp:/tmp"
env_file: ./base.env
environment:
# Port-prefixed URLs (host-specific)
NEXT_PUBLIC_STACK_PORT_PREFIX: "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}"
NEXT_PUBLIC_STACK_API_URL: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02"
NEXT_PUBLIC_STACK_DASHBOARD_URL: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01"
NEXT_PUBLIC_STACK_DOCS_BASE_URL: "https://docs.stack-auth.com"
NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR: "true"
NEXT_PUBLIC_STACK_PROJECT_ID: "internal"
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: "this-publishable-client-key-is-for-local-development-only"
STACK_SECRET_SERVER_KEY: "this-secret-server-key-is-for-local-development-only"
STACK_SERVER_SECRET: "23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo"
NEXT_PUBLIC_STACK_SVIX_SERVER_URL: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}13"
STACK_CHANGELOG_URL: "https://raw.githubusercontent.com/stack-auth/stack-auth/refs/heads/dev/CHANGELOG.md"
STACK_SEED_ENABLE_DUMMY_PROJECT: "true"
STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED: "true"
STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED: "true"
STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST: "true"
STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS: "github,spotify,google,microsoft"
STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS: "true"
STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY: "this-publishable-client-key-is-for-local-development-only"
STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY: "this-secret-server-key-is-for-local-development-only"
STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY: "this-super-secret-admin-key-is-for-local-development-only"
STACK_S3_PUBLIC_ENDPOINT: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}21/stack-storage"
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01/handler/email-verification"
STACK_OAUTH_MOCK_URL: "http://host.docker.internal:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}14"
STACK_GITHUB_CLIENT_ID: "MOCK"
STACK_GITHUB_CLIENT_SECRET: "MOCK"
STACK_GOOGLE_CLIENT_ID: "MOCK"
STACK_GOOGLE_CLIENT_SECRET: "MOCK"
STACK_MICROSOFT_CLIENT_ID: "MOCK"
STACK_MICROSOFT_CLIENT_SECRET: "MOCK"
STACK_SPOTIFY_CLIENT_ID: "MOCK"
STACK_SPOTIFY_CLIENT_SECRET: "MOCK"
STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS: "true"
BACKEND_PORT: "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02"
DASHBOARD_PORT: "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01"
# Deps service hostnames (stack-deps container, not localhost)
STACK_DATABASE_CONNECTION_STRING: "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@stack-deps:5432/stackframe"
STACK_EMAIL_HOST: "stack-deps"
STACK_EMAIL_PORT: "2500"
STACK_EMAIL_SECURE: "false"
STACK_EMAIL_USERNAME: "does-not-matter"
STACK_EMAIL_PASSWORD: "does-not-matter"
STACK_EMAIL_SENDER: "noreply@example.com"
STACK_DEFAULT_EMAIL_CAPACITY_PER_HOUR: "10000"
STACK_SVIX_SERVER_URL: "http://stack-deps:8071"
STACK_SVIX_API_KEY: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NTUxNDA2MzksImV4cCI6MTk3MDUwMDYzOSwibmJmIjoxNjU1MTQwNjM5LCJpc3MiOiJzdml4LXNlcnZlciIsInN1YiI6Im9yZ18yM3JiOFlkR3FNVDBxSXpwZ0d3ZFhmSGlyTXUifQ.En8w77ZJWbd0qrMlHHupHUB-4cx17RfzFykseg95SUk"
STACK_OPENAI_API_KEY: "mock_openai_api_key"
STACK_OPENROUTER_API_KEY: "FORWARD_TO_PRODUCTION"
STACK_STRIPE_SECRET_KEY: "sk_test_mockstripekey"
STACK_STRIPE_WEBHOOK_SECRET: "mock_stripe_webhook_secret"
STACK_RESEND_API_KEY: "mock_resend_api_key"
STACK_RESEND_WEBHOOK_SECRET: "mock_resend_webhook_secret"
STACK_DNSIMPLE_API_TOKEN: "mock_dnsimple_api_token"
STACK_DNSIMPLE_ACCOUNT_ID: "mock_dnsimple_account_id"
STACK_DNSIMPLE_API_BASE_URL: "https://api.dnsimple.com/v2"
STACK_FREESTYLE_API_KEY: "mock_stack_freestyle_key"
STACK_VERCEL_SANDBOX_TOKEN: "vercel_sandbox_disabled_for_local_development"
CRON_SECRET: "mock_cron_secret"
STACK_S3_ENDPOINT: "http://stack-deps:9090"
STACK_S3_PUBLIC_ENDPOINT: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}21/stack-storage"
STACK_S3_REGION: "us-east-1"
STACK_S3_ACCESS_KEY_ID: "s3mockroot"
STACK_S3_SECRET_ACCESS_KEY: "s3mockroot"
STACK_S3_BUCKET: "stack-storage"
STACK_S3_PRIVATE_BUCKET: "stack-storage-private"
STACK_AWS_REGION: "us-east-1"
STACK_AWS_ACCESS_KEY_ID: "test"
STACK_AWS_SECRET_ACCESS_KEY: "test"
STACK_QSTASH_URL: "http://stack-deps:8080"
STACK_QSTASH_TOKEN: "eyJVc2VySUQiOiJkZWZhdWx0VXNlciIsIlBhc3N3b3JkIjoiZGVmYXVsdFBhc3N3b3JkIn0="
STACK_QSTASH_CURRENT_SIGNING_KEY: "sig_7kYjw48mhY7kAjqNGcy6cr29RJ6r"
STACK_QSTASH_NEXT_SIGNING_KEY: "sig_5ZB6DVzB1wjE8S6rZ7eenA8Pdnhs"
STACK_CLICKHOUSE_URL: "http://stack-deps:8123"
STACK_CLICKHOUSE_ADMIN_USER: "stackframe"
STACK_CLICKHOUSE_ADMIN_PASSWORD: "PASSWORD-PLACEHOLDER--9gKyMxJeMx"
STACK_CLICKHOUSE_EXTERNAL_PASSWORD: "PASSWORD-PLACEHOLDER--EZeHscBMzE"
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01/handler/email-verification"
STACK_EMAIL_MONITOR_PROJECT_ID: "internal"
STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY: "this-publishable-client-key-is-for-local-development-only"
STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN: "stack-generated.example.com"
STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY: "this-is-a-fake-key"
STACK_EMAIL_MONITOR_INBUCKET_API_URL: "http://stack-deps:9001"
STACK_EMAIL_MONITOR_USE_INBUCKET: "true"
STACK_EMAIL_MONITOR_SECRET_TOKEN: "this-secret-token-is-for-local-development-only"
STACK_FEATUREBASE_JWT_SECRET: "secret-value"
STACK_FORWARD_MOCK_OAUTH_SERVER: "false"
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:

View File

@ -7,7 +7,6 @@ source "$SCRIPT_DIR/common.sh"
IMAGE_DIR="$SCRIPT_DIR/images"
CLOUD_INIT_ROOT="$SCRIPT_DIR/cloud-init"
PREPARE_IMAGE_BUNDLE_SCRIPT="$SCRIPT_DIR/prepare-image-bundle.sh"
DEBIAN_VERSION="${DEBIAN_VERSION:-13}"
DISK_SIZE="${EMULATOR_DISK_SIZE:-12G}"
@ -140,7 +139,16 @@ prepare_bundle_artifacts() {
fi
log "Creating Docker image bundle (${arch})..."
"$PREPARE_IMAGE_BUNDLE_SCRIPT" "$bundle_tgz" "${DOCKER_IMAGES[@]}"
for img in "${DOCKER_IMAGES[@]}"; do
if ! docker image inspect "$img" >/dev/null 2>&1; then
err "Missing Docker image: $img. Build the local emulator images first, then rerun the QEMU image build."
exit 1
fi
done
local tmp_bundle="${bundle_tgz}.tmp"
rm -f "$tmp_bundle"
docker save "${DOCKER_IMAGES[@]}" | gzip -c > "$tmp_bundle"
mv "$tmp_bundle" "$bundle_tgz"
printf "%s" "$current_ids" > "$bundle_meta"
}

View File

@ -15,7 +15,7 @@ chpasswd:
stack:stack-emulator
expire: false
ssh_pwauth: true
ssh_pwauth: false
package_update: true
package_upgrade: false
@ -28,68 +28,6 @@ packages:
- qemu-guest-agent
write_files:
- path: /etc/stack-auth/local-emulator.base.env
content: |
NEXT_PUBLIC_STACK_DOCS_BASE_URL=https://docs.stack-auth.com
NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true
NEXT_PUBLIC_STACK_PROJECT_ID=internal
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
STACK_SECRET_SERVER_KEY=this-secret-server-key-is-for-local-development-only
STACK_SERVER_SECRET=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo
STACK_CHANGELOG_URL=https://raw.githubusercontent.com/stack-auth/stack-auth/refs/heads/dev/CHANGELOG.md
STACK_SEED_ENABLE_DUMMY_PROJECT=true
STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=true
STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED=true
STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST=true
STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS=github,spotify,google,microsoft
STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS=true
STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=this-secret-server-key-is-for-local-development-only
STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=this-super-secret-admin-key-is-for-local-development-only
STACK_SVIX_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NTUxNDA2MzksImV4cCI6MTk3MDUwMDYzOSwibmJmIjoxNjU1MTQwNjM5LCJpc3MiOiJzdml4LXNlcnZlciIsInN1YiI6Im9yZ18yM3JiOFlkR3FNVDBxSXpwZ0d3ZFhmSGlyTXUifQ.En8w77ZJWbd0qrMlHHupHUB-4cx17RfzFykseg95SUk
STACK_OPENAI_API_KEY=mock_openai_api_key
STACK_OPENROUTER_API_KEY=FORWARD_TO_PRODUCTION
STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey
STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret
STACK_RESEND_API_KEY=mock_resend_api_key
STACK_RESEND_WEBHOOK_SECRET=mock_resend_webhook_secret
STACK_DNSIMPLE_API_TOKEN=mock_dnsimple_api_token
STACK_DNSIMPLE_ACCOUNT_ID=mock_dnsimple_account_id
STACK_DNSIMPLE_API_BASE_URL=https://api.dnsimple.com/v2
STACK_FREESTYLE_API_KEY=mock_stack_freestyle_key
STACK_VERCEL_SANDBOX_TOKEN=vercel_sandbox_disabled_for_local_development
CRON_SECRET=mock_cron_secret
STACK_S3_REGION=us-east-1
STACK_S3_ACCESS_KEY_ID=s3mockroot
STACK_S3_SECRET_ACCESS_KEY=s3mockroot
STACK_S3_BUCKET=stack-storage
STACK_S3_PRIVATE_BUCKET=stack-storage-private
STACK_AWS_REGION=us-east-1
STACK_AWS_ACCESS_KEY_ID=test
STACK_AWS_SECRET_ACCESS_KEY=test
STACK_QSTASH_TOKEN=eyJVc2VySUQiOiJkZWZhdWx0VXNlciIsIlBhc3N3b3JkIjoiZGVmYXVsdFBhc3N3b3JkIn0=
STACK_QSTASH_CURRENT_SIGNING_KEY=sig_7kYjw48mhY7kAjqNGcy6cr29RJ6r
STACK_QSTASH_NEXT_SIGNING_KEY=sig_5ZB6DVzB1wjE8S6rZ7eenA8Pdnhs
STACK_CLICKHOUSE_ADMIN_USER=stackframe
STACK_CLICKHOUSE_ADMIN_PASSWORD=PASSWORD-PLACEHOLDER--9gKyMxJeMx
STACK_CLICKHOUSE_EXTERNAL_PASSWORD=PASSWORD-PLACEHOLDER--EZeHscBMzE
STACK_EMAIL_MONITOR_PROJECT_ID=internal
STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN=stack-generated.example.com
STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY=this-is-a-fake-key
STACK_EMAIL_MONITOR_SECRET_TOKEN=this-secret-token-is-for-local-development-only
STACK_FEATUREBASE_JWT_SECRET=secret-value
STACK_FORWARD_MOCK_OAUTH_SERVER=false
STACK_GITHUB_CLIENT_ID=MOCK
STACK_GITHUB_CLIENT_SECRET=MOCK
STACK_GOOGLE_CLIENT_ID=MOCK
STACK_GOOGLE_CLIENT_SECRET=MOCK
STACK_MICROSOFT_CLIENT_ID=MOCK
STACK_MICROSOFT_CLIENT_SECRET=MOCK
STACK_SPOTIFY_CLIENT_ID=MOCK
STACK_SPOTIFY_CLIENT_SECRET=MOCK
STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS=true
- path: /usr/local/bin/install-emulator-containers
permissions: '0755'
content: |
@ -135,103 +73,43 @@ write_files:
set -a
source /mnt/stack-runtime/runtime.env
source /etc/stack-auth/local-emulator.base.env
source /mnt/stack-runtime/base.env
set +a
# Deps runs in the same VM, so always localhost
DEPS_HOST=127.0.0.1
P="$STACK_EMULATOR_PORT_PREFIX"
cat > /run/stack-auth/local-emulator.env <<EOF
NEXT_PUBLIC_STACK_PORT_PREFIX=${STACK_EMULATOR_PORT_PREFIX}
STACK_LOCAL_EMULATOR_FILE_BRIDGE_URL=${STACK_LOCAL_EMULATOR_FILE_BRIDGE_URL}
STACK_LOCAL_EMULATOR_FILE_BRIDGE_TOKEN=${STACK_LOCAL_EMULATOR_FILE_BRIDGE_TOKEN}
{
# Static vars from base config and runtime (e.g. API keys, feature flags)
cat /mnt/stack-runtime/base.env
cat /mnt/stack-runtime/runtime.env
# Computed vars — depend on port prefix or deps host
cat <<COMPUTED
NEXT_PUBLIC_STACK_PORT_PREFIX=${P}
STACK_RUNTIME_WORK_DIR=/app
NEXT_PUBLIC_STACK_API_URL=http://localhost:${STACK_EMULATOR_PORT_PREFIX}02
NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:${STACK_EMULATOR_PORT_PREFIX}01
NEXT_PUBLIC_BROWSER_STACK_API_URL=http://localhost:${STACK_EMULATOR_PORT_PREFIX}02
NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL=http://localhost:${STACK_EMULATOR_PORT_PREFIX}01
NEXT_PUBLIC_SERVER_STACK_API_URL=http://127.0.0.1:${STACK_EMULATOR_PORT_PREFIX}02
NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL=http://127.0.0.1:${STACK_EMULATOR_PORT_PREFIX}01
NEXT_PUBLIC_STACK_SVIX_SERVER_URL=http://localhost:${STACK_EMULATOR_PORT_PREFIX}13
NEXT_PUBLIC_STACK_API_URL=http://localhost:${P}02
NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:${P}01
NEXT_PUBLIC_BROWSER_STACK_API_URL=http://localhost:${P}02
NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL=http://localhost:${P}01
NEXT_PUBLIC_SERVER_STACK_API_URL=http://127.0.0.1:${P}02
NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL=http://127.0.0.1:${P}01
NEXT_PUBLIC_STACK_SVIX_SERVER_URL=http://localhost:${P}13
STACK_DATABASE_CONNECTION_STRING=postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@${DEPS_HOST}:5432/stackframe
STACK_EMAIL_HOST=${DEPS_HOST}
STACK_EMAIL_PORT=2500
STACK_EMAIL_SECURE=false
STACK_EMAIL_USERNAME=does-not-matter
STACK_EMAIL_PASSWORD=does-not-matter
STACK_EMAIL_SENDER=noreply@example.com
STACK_DEFAULT_EMAIL_CAPACITY_PER_HOUR=10000
STACK_SVIX_SERVER_URL=http://${DEPS_HOST}:8071
STACK_S3_ENDPOINT=http://${DEPS_HOST}:9090
STACK_S3_PUBLIC_ENDPOINT=http://localhost:${STACK_EMULATOR_PORT_PREFIX}21/stack-storage
STACK_S3_PUBLIC_ENDPOINT=http://localhost:${P}21/stack-storage
STACK_QSTASH_URL=http://${DEPS_HOST}:8080
STACK_CLICKHOUSE_URL=http://${DEPS_HOST}:8123
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:${STACK_EMULATOR_PORT_PREFIX}01/handler/email-verification
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:${P}01/handler/email-verification
STACK_EMAIL_MONITOR_INBUCKET_API_URL=http://${DEPS_HOST}:9001
STACK_EMAIL_MONITOR_USE_INBUCKET=true
STACK_OAUTH_MOCK_URL=http://${DEPS_HOST}:${STACK_EMULATOR_PORT_PREFIX}14
BACKEND_PORT=${STACK_EMULATOR_PORT_PREFIX}02
DASHBOARD_PORT=${STACK_EMULATOR_PORT_PREFIX}01
NEXT_PUBLIC_STACK_DOCS_BASE_URL=${NEXT_PUBLIC_STACK_DOCS_BASE_URL}
NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=${NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR}
NEXT_PUBLIC_STACK_PROJECT_ID=${NEXT_PUBLIC_STACK_PROJECT_ID}
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY}
STACK_SECRET_SERVER_KEY=${STACK_SECRET_SERVER_KEY}
STACK_SERVER_SECRET=${STACK_SERVER_SECRET}
STACK_CHANGELOG_URL=${STACK_CHANGELOG_URL}
STACK_SEED_ENABLE_DUMMY_PROJECT=${STACK_SEED_ENABLE_DUMMY_PROJECT}
STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=${STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED}
STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED=${STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED}
STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST=${STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST}
STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS=${STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS}
STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS=${STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS}
STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY}
STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY}
STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=${STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY}
STACK_SVIX_API_KEY=${STACK_SVIX_API_KEY}
STACK_OPENAI_API_KEY=${STACK_OPENAI_API_KEY}
STACK_OPENROUTER_API_KEY=${STACK_OPENROUTER_API_KEY}
STACK_STRIPE_SECRET_KEY=${STACK_STRIPE_SECRET_KEY}
STACK_STRIPE_WEBHOOK_SECRET=${STACK_STRIPE_WEBHOOK_SECRET}
STACK_RESEND_API_KEY=${STACK_RESEND_API_KEY}
STACK_RESEND_WEBHOOK_SECRET=${STACK_RESEND_WEBHOOK_SECRET}
STACK_DNSIMPLE_API_TOKEN=${STACK_DNSIMPLE_API_TOKEN}
STACK_DNSIMPLE_ACCOUNT_ID=${STACK_DNSIMPLE_ACCOUNT_ID}
STACK_DNSIMPLE_API_BASE_URL=${STACK_DNSIMPLE_API_BASE_URL}
STACK_FREESTYLE_API_KEY=${STACK_FREESTYLE_API_KEY}
STACK_VERCEL_SANDBOX_TOKEN=${STACK_VERCEL_SANDBOX_TOKEN}
CRON_SECRET=${CRON_SECRET}
STACK_S3_REGION=${STACK_S3_REGION}
STACK_S3_ACCESS_KEY_ID=${STACK_S3_ACCESS_KEY_ID}
STACK_S3_SECRET_ACCESS_KEY=${STACK_S3_SECRET_ACCESS_KEY}
STACK_S3_BUCKET=${STACK_S3_BUCKET}
STACK_S3_PRIVATE_BUCKET=${STACK_S3_PRIVATE_BUCKET}
STACK_AWS_REGION=${STACK_AWS_REGION}
STACK_AWS_ACCESS_KEY_ID=${STACK_AWS_ACCESS_KEY_ID}
STACK_AWS_SECRET_ACCESS_KEY=${STACK_AWS_SECRET_ACCESS_KEY}
STACK_QSTASH_TOKEN=${STACK_QSTASH_TOKEN}
STACK_QSTASH_CURRENT_SIGNING_KEY=${STACK_QSTASH_CURRENT_SIGNING_KEY}
STACK_QSTASH_NEXT_SIGNING_KEY=${STACK_QSTASH_NEXT_SIGNING_KEY}
STACK_CLICKHOUSE_ADMIN_USER=${STACK_CLICKHOUSE_ADMIN_USER}
STACK_CLICKHOUSE_ADMIN_PASSWORD=${STACK_CLICKHOUSE_ADMIN_PASSWORD}
STACK_CLICKHOUSE_EXTERNAL_PASSWORD=${STACK_CLICKHOUSE_EXTERNAL_PASSWORD}
STACK_EMAIL_MONITOR_PROJECT_ID=${STACK_EMAIL_MONITOR_PROJECT_ID}
STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY=${STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY}
STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN=${STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN}
STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY=${STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY}
STACK_EMAIL_MONITOR_SECRET_TOKEN=${STACK_EMAIL_MONITOR_SECRET_TOKEN}
STACK_FEATUREBASE_JWT_SECRET=${STACK_FEATUREBASE_JWT_SECRET}
STACK_FORWARD_MOCK_OAUTH_SERVER=${STACK_FORWARD_MOCK_OAUTH_SERVER}
STACK_GITHUB_CLIENT_ID=${STACK_GITHUB_CLIENT_ID}
STACK_GITHUB_CLIENT_SECRET=${STACK_GITHUB_CLIENT_SECRET}
STACK_GOOGLE_CLIENT_ID=${STACK_GOOGLE_CLIENT_ID}
STACK_GOOGLE_CLIENT_SECRET=${STACK_GOOGLE_CLIENT_SECRET}
STACK_MICROSOFT_CLIENT_ID=${STACK_MICROSOFT_CLIENT_ID}
STACK_MICROSOFT_CLIENT_SECRET=${STACK_MICROSOFT_CLIENT_SECRET}
STACK_SPOTIFY_CLIENT_ID=${STACK_SPOTIFY_CLIENT_ID}
STACK_SPOTIFY_CLIENT_SECRET=${STACK_SPOTIFY_CLIENT_SECRET}
STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS=${STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS}
EOF
STACK_OAUTH_MOCK_URL=http://${DEPS_HOST}:${P}14
BACKEND_PORT=${P}02
DASHBOARD_PORT=${P}01
COMPUTED
} > /run/stack-auth/local-emulator.env
- path: /usr/local/bin/run-stack-app-container
permissions: '0755'
@ -296,7 +174,8 @@ write_files:
WantedBy=multi-user.target
runcmd:
- mkdir -p /etc/stack-auth
- systemctl disable --now ssh || true
- systemctl mask ssh || true
- bash /usr/local/bin/install-emulator-containers
- systemctl daemon-reload
- systemctl enable stack-deps.service

View File

@ -1,2 +0,0 @@
instance-id: stack-emulator-split
local-hostname: stack-emulator

View File

@ -1,6 +0,0 @@
#cloud-config
write_files:
- path: /etc/motd
content: |
Stack Auth local emulator uses the unified cloud-init definition in
docker/local-emulator/qemu/cloud-init/emulator/.

View File

@ -1,139 +0,0 @@
#!/usr/bin/env node
import http from "node:http";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const port = Number.parseInt(process.env.STACK_QEMU_FILE_BRIDGE_PORT ?? "", 10) || 8116;
const host = process.env.STACK_QEMU_FILE_BRIDGE_HOST ?? "0.0.0.0";
const token = process.env.STACK_QEMU_FILE_BRIDGE_TOKEN ?? "";
if (token === "") {
console.error("STACK_QEMU_FILE_BRIDGE_TOKEN is required");
process.exit(1);
}
const allowedRoots = [os.homedir(), "/tmp"].map((root) => path.resolve(root));
function isWithinRoot(filePath, rootPath) {
return filePath === rootPath || filePath.startsWith(`${rootPath}${path.sep}`);
}
function isAllowedPath(filePath) {
return allowedRoots.some((rootPath) => isWithinRoot(filePath, rootPath));
}
function sendJson(res, statusCode, body) {
res.writeHead(statusCode, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
}
function sendText(res, statusCode, message) {
res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8" });
res.end(message);
}
async function readBody(req) {
let body = "";
for await (const chunk of req) {
body += chunk.toString();
}
return body;
}
function parseRequestBody(bodyText) {
try {
const body = JSON.parse(bodyText);
return typeof body === "object" && body !== null ? body : null;
} catch {
return null;
}
}
async function handleRead(res, body) {
const requestedPath = body.path;
if (typeof requestedPath !== "string") {
sendText(res, 400, "Body must include a string 'path'.");
return;
}
const filePath = path.resolve(requestedPath);
if (!isAllowedPath(filePath)) {
sendText(res, 403, `Path is not allowed: ${filePath}`);
return;
}
try {
const content = await fs.readFile(filePath, "utf-8");
sendJson(res, 200, { exists: true, content });
} catch (error) {
if (error?.code === "ENOENT") {
sendJson(res, 200, { exists: false });
return;
}
throw error;
}
}
async function handleWrite(res, body) {
const requestedPath = body.path;
const content = body.content;
if (typeof requestedPath !== "string" || typeof content !== "string") {
sendText(res, 400, "Body must include string 'path' and 'content' fields.");
return;
}
const filePath = path.resolve(requestedPath);
if (!isAllowedPath(filePath)) {
sendText(res, 403, `Path is not allowed: ${filePath}`);
return;
}
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, "utf-8");
sendJson(res, 200, { ok: true });
}
const server = http.createServer(async (req, res) => {
try {
if (req.url === "/health") {
sendText(res, 200, "ok");
return;
}
if (req.method !== "POST") {
sendText(res, 405, "Method not allowed");
return;
}
if (req.headers["x-stack-emulator-token"] !== token) {
sendText(res, 401, "Unauthorized");
return;
}
const requestBody = parseRequestBody(await readBody(req));
if (requestBody === null) {
sendText(res, 400, "Invalid JSON body.");
return;
}
if (req.url === "/read") {
await handleRead(res, requestBody);
return;
}
if (req.url === "/write") {
await handleWrite(res, requestBody);
return;
}
sendText(res, 404, "Not found");
} catch (error) {
sendText(res, 500, error instanceof Error ? error.message : String(error));
}
});
server.listen(port, host, () => {
console.log(`stack-qemu-file-bridge listening on ${host}:${port}`);
});

View File

@ -1,5 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec "$SCRIPT_DIR/prepare-image-bundle.sh" "${1:-}" stack-local-emulator-app

View File

@ -1,36 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTPUT_PATH="${1:-}"
shift || true
if [ -z "$OUTPUT_PATH" ] || [ "$#" -eq 0 ]; then
echo "Usage: $0 <output-tar.gz> <docker-image> [docker-image...]" >&2
exit 1
fi
if ! command -v docker >/dev/null 2>&1; then
echo "docker is required to package emulator images" >&2
exit 1
fi
for IMAGE_NAME in "$@"; do
if ! docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then
cat >&2 <<EOF
Missing Docker image: $IMAGE_NAME
Build the local emulator images first, then rerun the QEMU image build.
Expected images:
- stack-local-emulator-deps
- stack-local-emulator-app
EOF
exit 1
fi
done
mkdir -p "$(dirname "$OUTPUT_PATH")"
tmp_output="${OUTPUT_PATH}.tmp"
rm -f "$tmp_output"
docker save "$@" | gzip -c > "$tmp_output"
mv "$tmp_output" "$OUTPUT_PATH"

View File

@ -7,13 +7,12 @@ source "$SCRIPT_DIR/common.sh"
IMAGE_DIR="$SCRIPT_DIR/images"
RUN_DIR="/tmp/stack-emulator-run"
FILE_BRIDGE_SCRIPT="$SCRIPT_DIR/host-file-bridge.mjs"
VM_RAM="${EMULATOR_RAM:-4096}"
VM_CPUS="${EMULATOR_CPUS:-4}"
PORT_PREFIX="${PORT_PREFIX:-${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}}"
FILE_BRIDGE_PORT="${EMULATOR_FILE_BRIDGE_PORT:-${PORT_PREFIX}16}"
READY_TIMEOUT="${EMULATOR_READY_TIMEOUT:-240}"
CONFIG_FILE=""
RED='\033[0;31m'
GREEN='\033[0;32m'
@ -26,113 +25,6 @@ warn() { echo -e "${YELLOW}[emulator]${NC} $*"; }
err() { echo -e "${RED}[emulator]${NC} $*" >&2; }
info() { echo -e "${CYAN}[emulator]${NC} $*"; }
file_bridge_pidfile() {
echo "$RUN_DIR/host-file-bridge.pid"
}
file_bridge_logfile() {
echo "$RUN_DIR/host-file-bridge.log"
}
file_bridge_tokenfile() {
echo "$RUN_DIR/host-file-bridge.token"
}
ensure_file_bridge_token() {
local token_file
token_file="$(file_bridge_tokenfile)"
# Deterministic token so snapshots can reuse the same value across restarts
FILE_BRIDGE_TOKEN="$(printf 'stack-local-emulator-%s' "$PORT_PREFIX" | shasum -a 256 | head -c 48)"
mkdir -p "$RUN_DIR"
printf "%s" "$FILE_BRIDGE_TOKEN" > "$token_file"
}
is_file_bridge_running() {
local pidfile
pidfile="$(file_bridge_pidfile)"
if [ ! -f "$pidfile" ]; then
return 1
fi
local pid
pid="$(cat "$pidfile")"
kill -0 "$pid" 2>/dev/null
}
start_file_bridge() {
ensure_file_bridge_token
if is_file_bridge_running; then
return 0
fi
if [ ! -f "$FILE_BRIDGE_SCRIPT" ]; then
err "Missing host file bridge script: $FILE_BRIDGE_SCRIPT"
exit 1
fi
local pid
pid="$(
STACK_QEMU_FILE_BRIDGE_PORT="$FILE_BRIDGE_PORT" \
STACK_QEMU_FILE_BRIDGE_HOST="0.0.0.0" \
STACK_QEMU_FILE_BRIDGE_TOKEN="$FILE_BRIDGE_TOKEN" \
python3 - "$FILE_BRIDGE_SCRIPT" "$(file_bridge_logfile)" <<'PY'
import os
import subprocess
import sys
script_path = sys.argv[1]
log_path = sys.argv[2]
with open(log_path, "ab", buffering=0) as log_file:
process = subprocess.Popen(
["node", script_path],
stdin=subprocess.DEVNULL,
stdout=log_file,
stderr=log_file,
start_new_session=True,
env=os.environ.copy(),
close_fds=True,
)
print(process.pid)
PY
)"
echo "$pid" > "$(file_bridge_pidfile)"
local elapsed=0
while [ "$elapsed" -lt 15 ]; do
if curl -sf "http://127.0.0.1:${FILE_BRIDGE_PORT}/health" >/dev/null 2>&1; then
return 0
fi
if ! kill -0 "$pid" 2>/dev/null; then
err "Host file bridge exited unexpectedly."
tail -40 "$(file_bridge_logfile)" 2>/dev/null || true
exit 1
fi
sleep 1
elapsed=$((elapsed + 1))
done
err "Timed out waiting for host file bridge on port ${FILE_BRIDGE_PORT}."
tail -40 "$(file_bridge_logfile)" 2>/dev/null || true
exit 1
}
stop_file_bridge() {
local pidfile
pidfile="$(file_bridge_pidfile)"
if [ ! -f "$pidfile" ]; then
return 0
fi
local pid
pid="$(cat "$pidfile")"
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
sleep 1
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$pidfile" "$(file_bridge_logfile)" "$(file_bridge_tokenfile)"
}
detect_host
ARCH="${EMULATOR_ARCH:-$HOST_ARCH}"
@ -221,11 +113,13 @@ prepare_runtime_config_iso() {
cfg_iso="$(runtime_iso_path)"
rm -rf "$cfg_dir"
mkdir -p "$cfg_dir"
cat > "$cfg_dir/runtime.env" <<EOF
STACK_EMULATOR_PORT_PREFIX=$PORT_PREFIX
STACK_LOCAL_EMULATOR_FILE_BRIDGE_URL=http://10.0.2.2:${FILE_BRIDGE_PORT}
STACK_LOCAL_EMULATOR_FILE_BRIDGE_TOKEN=${FILE_BRIDGE_TOKEN}
EOF
{
printf "STACK_EMULATOR_PORT_PREFIX=%s\n" "$PORT_PREFIX"
if [ -n "$CONFIG_FILE" ]; then
printf "STACK_LOCAL_EMULATOR_CONFIG_CONTENT=%s\n" "$(base64 < "$CONFIG_FILE")"
fi
} > "$cfg_dir/runtime.env"
cp "$SCRIPT_DIR/../base.env" "$cfg_dir/base.env"
make_iso_from_dir "$cfg_iso" "STACKCFG" "$cfg_dir"
}
@ -332,7 +226,6 @@ build_qemu_cmd() {
esac
local netdev="user,id=net0"
netdev+=",hostfwd=tcp::${PORT_PREFIX}22-:22"
# Deps services
netdev+=",hostfwd=tcp::${PORT_PREFIX}28-:5432"
netdev+=",hostfwd=tcp::${PORT_PREFIX}29-:2500"
@ -393,7 +286,7 @@ tail_vm_logs() {
}
ensure_ports_free() {
local ports=("${PORT_PREFIX}01" "${PORT_PREFIX}02" "${PORT_PREFIX}05" "${PORT_PREFIX}13" "${PORT_PREFIX}16" "${PORT_PREFIX}21" "${PORT_PREFIX}25" "${PORT_PREFIX}28" "${PORT_PREFIX}29" "${PORT_PREFIX}30" "${PORT_PREFIX}36" "${PORT_PREFIX}37")
local ports=("${PORT_PREFIX}01" "${PORT_PREFIX}02" "${PORT_PREFIX}05" "${PORT_PREFIX}13" "${PORT_PREFIX}21" "${PORT_PREFIX}25" "${PORT_PREFIX}28" "${PORT_PREFIX}29" "${PORT_PREFIX}30" "${PORT_PREFIX}36" "${PORT_PREFIX}37")
local port
for port in "${ports[@]}"; do
if lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
@ -435,9 +328,13 @@ stop_vm() {
}
cmd_start() {
if [ -n "$CONFIG_FILE" ] && [ ! -f "$CONFIG_FILE" ]; then
err "Config file not found: $CONFIG_FILE"
exit 1
fi
ensure_ports_free
mkdir -p "$RUN_DIR"
start_file_bridge
IS_SNAPSHOT_RESTORE=false
@ -477,7 +374,6 @@ cmd_start() {
cmd_stop() {
stop_vm
stop_file_bridge
log "QEMU emulator stopped."
}
@ -513,7 +409,6 @@ cmd_status() {
print_service_status "Backend" "${PORT_PREFIX}02" http "/health?db=1"
print_service_status "PostgreSQL" "${PORT_PREFIX}28" tcp
print_service_status "Inbucket HTTP" "${PORT_PREFIX}05" http /
print_service_status "Host File Bridge" "${FILE_BRIDGE_PORT}" http /health
print_service_status "Svix" "${PORT_PREFIX}13" http /api/v1/health/
print_service_status "MinIO" "${PORT_PREFIX}21" http /minio/health/live
print_service_status "QStash" "${PORT_PREFIX}25" http / 401
@ -541,7 +436,24 @@ print(f"Startup time: {end_time - start_time:.1f}s")
PY
}
ACTION="${1:-start}"
ACTION="start"
while [[ $# -gt 0 ]]; do
case "$1" in
--config-file)
CONFIG_FILE="$2"
shift 2
;;
start|stop|reset|status|bench)
ACTION="$1"
shift
;;
*)
echo "Usage: $0 [start|stop|reset|status|bench] [--config-file <path>]"
exit 1
;;
esac
done
case "$ACTION" in
start) cmd_start ;;
@ -549,8 +461,4 @@ case "$ACTION" in
reset) cmd_reset ;;
status) cmd_status ;;
bench) cmd_bench ;;
*)
echo "Usage: $0 [start|stop|reset|status|bench]"
exit 1
;;
esac

View File

@ -137,7 +137,7 @@ export default function EmulatorStatusPage() {
{summary.down > 0 && (
<span className="text-red-600 dark:text-red-400 font-medium">{summary.down} down</span>
)}
{data != null && <span>updated {new Date(data.timestamp).toLocaleTimeString()}</span>}
<span>updated {new Date(data.timestamp).toLocaleTimeString()}</span>
</div>
</div>
) : null}