Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ jobs:
node-version-file: .nvmrc
cache: "pnpm"

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: "1.3.14"

- name: Install dependencies
run: pnpm install --frozen-lockfile

Expand Down
12 changes: 12 additions & 0 deletions packages/eve/src/internal/nitro/dev-client-address.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ describe("development client address metadata", () => {
expect(readTrustedDevelopmentClientAddress(headers, SECRET)).toBe("10.0.0.9");
});

it("stamps Node request headers used by WebSocket upgrades", () => {
const headers = {
[DEVELOPMENT_CLIENT_ADDRESS_HEADER]: "203.0.113.7",
[DEVELOPMENT_CLIENT_ADDRESS_SIGNATURE_HEADER]: "forged",
};
stampDevelopmentClientAddress(headers, "10.0.0.9", SECRET);

expect(headers[DEVELOPMENT_CLIENT_ADDRESS_HEADER]).toBe("10.0.0.9");
expect(headers[DEVELOPMENT_CLIENT_ADDRESS_SIGNATURE_HEADER]).not.toBe("forged");
expect(readTrustedDevelopmentClientAddress(new Headers(headers), SECRET)).toBe("10.0.0.9");
});

it("drops the metadata entirely when no secret or address is available", () => {
const headers = new Headers({
[DEVELOPMENT_CLIENT_ADDRESS_HEADER]: "203.0.113.7",
Expand Down
31 changes: 26 additions & 5 deletions packages/eve/src/internal/nitro/dev-client-address.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
import type { IncomingHttpHeaders } from "node:http";

export const DEVELOPMENT_CLIENT_ADDRESS_HEADER = "x-eve-dev-client-address";
export const DEVELOPMENT_CLIENT_ADDRESS_SIGNATURE_HEADER = "x-eve-dev-client-address-signature";
Expand All @@ -11,12 +12,12 @@ export const DEVELOPMENT_CLIENT_ADDRESS_SIGNATURE_HEADER = "x-eve-dev-client-add
* unsigned header could be forged by anything that discovers it.
*/
export function stampDevelopmentClientAddress(
headers: Headers,
headers: Headers | IncomingHttpHeaders,
address: string | undefined,
secret: string | undefined,
): void {
headers.delete(DEVELOPMENT_CLIENT_ADDRESS_HEADER);
headers.delete(DEVELOPMENT_CLIENT_ADDRESS_SIGNATURE_HEADER);
deleteHeader(headers, DEVELOPMENT_CLIENT_ADDRESS_HEADER);
deleteHeader(headers, DEVELOPMENT_CLIENT_ADDRESS_SIGNATURE_HEADER);
if (
address === undefined ||
address.length === 0 ||
Expand All @@ -25,8 +26,12 @@ export function stampDevelopmentClientAddress(
) {
return;
}
headers.set(DEVELOPMENT_CLIENT_ADDRESS_HEADER, address);
headers.set(DEVELOPMENT_CLIENT_ADDRESS_SIGNATURE_HEADER, signClientAddress(address, secret));
setHeader(headers, DEVELOPMENT_CLIENT_ADDRESS_HEADER, address);
setHeader(
headers,
DEVELOPMENT_CLIENT_ADDRESS_SIGNATURE_HEADER,
signClientAddress(address, secret),
);
}

/**
Expand Down Expand Up @@ -65,3 +70,19 @@ export function timingSafeEqualStrings(a: string, b: string): boolean {
function signClientAddress(address: string, secret: string): string {
return createHmac("sha256", secret).update(address).digest("base64url");
}

function deleteHeader(headers: Headers | IncomingHttpHeaders, name: string): void {
if (headers instanceof Headers) {
headers.delete(name);
return;
}
delete headers[name];
}

function setHeader(headers: Headers | IncomingHttpHeaders, name: string, value: string): void {
if (headers instanceof Headers) {
headers.set(name, value);
return;
}
headers[name] = value;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type { CompileAgentResult } from "#compiler/compile-agent.js";
import {
createDevelopmentAuthoredRebuildCoordinator,
DevelopmentWorkflowWorldChangeRequiresRestartError,
PostCommitDevelopmentRebuildError,
} from "#internal/nitro/host/dev-authored-rebuild-coordinator.js";
import { DrainedNitroDevServer } from "#internal/nitro/host/drained-nitro-dev-server.js";
Expand Down Expand Up @@ -59,10 +60,21 @@
startDevelopmentSandboxPrewarmInBackground: vi.fn(),
}));

function createHost(id: string, runtimeFingerprint: string): PreparedDevelopmentApplicationHost {
function createHost(
id: string,
runtimeFingerprint: string,
configuredWorld?: string,
): PreparedDevelopmentApplicationHost {
return {
appRoot: "/tmp/eve-test",
compileResult: {
manifest: {
config: {
experimental: {
workflow: configuredWorld === undefined ? undefined : { world: configuredWorld },
},
},
},
project: { agentRoot: "/tmp/eve-test/agent" },
} as CompileAgentResult,
compiledArtifacts: {
Expand Down Expand Up @@ -96,7 +108,7 @@
return {
close: async () => {
closed = true;
for (const listener of [...closedListeners]) {

Check warning on line 111 in packages/eve/src/internal/nitro/host/dev-authored-rebuild-coordinator.test.ts

View workflow job for this annotation

GitHub Actions / lint

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
listener();
}
closedListeners.clear();
Expand Down Expand Up @@ -195,4 +207,35 @@
expect(mocks.environmentCommit).not.toHaveBeenCalled();
await devServer.close();
});

it.each([
{ initialWorld: undefined, nextWorld: "@example/custom-world" },
{ initialWorld: "@example/custom-world", nextWorld: "local" },
])(
"requires a restart when Workflow World ownership changes from $initialWorld to $nextWorld",
async ({ initialWorld, nextWorld }) => {
const devServer = new DrainedNitroDevServer({ error: () => undefined }, createRunner);
mocks.computeDevelopmentHostFingerprint.mockResolvedValueOnce("host-1");
const coordinator = await createDevelopmentAuthoredRebuildCoordinator({
devServer,
initialHost: createHost("initial", "run-1", initialWorld),
});
const candidate = createHost("candidate", "run-2", nextWorld);
mocks.prepareDevelopmentApplicationHost.mockResolvedValueOnce(candidate);

await expect(coordinator.rebuild({ changedPaths: [] })).rejects.toBeInstanceOf(
DevelopmentWorkflowWorldChangeRequiresRestartError,
);

expect(mocks.computeDevelopmentHostFingerprint).toHaveBeenCalledOnce();
expect(mocks.createDevelopmentApplicationNitro).not.toHaveBeenCalled();
expect(mocks.buildDevelopmentHostCandidate).not.toHaveBeenCalled();
expect(mocks.removeDevelopmentHostWorkspace).toHaveBeenCalledWith(candidate.workspace);
expect(mocks.discardDevelopmentGeneration).toHaveBeenCalledWith(candidate.generation);
expect(mocks.environmentRollback).toHaveBeenCalledOnce();
expect(mocks.environmentCommit).not.toHaveBeenCalled();

await devServer.close();
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { computeDevelopmentHostFingerprint } from "#internal/nitro/host/dev-host
import { removeDevelopmentHostWorkspace } from "#internal/nitro/host/dev-host-workspace.js";
import { prepareDevelopmentApplicationHost } from "#internal/nitro/host/prepare-application-host.js";
import { DrainedNitroDevServer } from "#internal/nitro/host/drained-nitro-dev-server.js";
import { usesParentDevelopmentWorkflowWorld } from "#internal/workflow/development-world-protocol.js";
import type { PreparedDevelopmentApplicationHost } from "#internal/nitro/host/types.js";
import {
activateDevelopmentGeneration,
Expand All @@ -33,6 +34,20 @@ export class PostCommitDevelopmentRebuildError extends Error {
}
}

/**
* Raised when an authored reload changes which process owns the Workflow
* World. That ownership is fixed when the development server starts, so the
* existing server must keep serving until the user restarts it.
*/
export class DevelopmentWorkflowWorldChangeRequiresRestartError extends Error {
constructor() {
super(
"Changing experimental.workflow.world between the parent-owned local World and a custom World requires restarting eve dev.",
);
this.name = "DevelopmentWorkflowWorldChangeRequiresRestartError";
}
}

export interface DevelopmentRebuildResult {
readonly host: PreparedDevelopmentApplicationHost;
readonly kind: DevelopmentRebuildKind;
Expand Down Expand Up @@ -69,6 +84,7 @@ class TransactionalDevelopmentAuthoredRebuildCoordinator implements DevelopmentA
#currentHostFingerprint: string;
#currentRuntimeFingerprint: string;
readonly #devServer: DrainedNitroDevServer;
readonly #usesParentWorkflowWorld: boolean;

constructor(input: {
readonly currentHostFingerprint: string;
Expand All @@ -80,6 +96,9 @@ class TransactionalDevelopmentAuthoredRebuildCoordinator implements DevelopmentA
this.#currentHostFingerprint = input.currentHostFingerprint;
this.#currentRuntimeFingerprint = input.currentRuntimeFingerprint;
this.#devServer = input.devServer;
this.#usesParentWorkflowWorld = usesParentDevelopmentWorkflowWorld(
input.initialHost.compileResult.manifest.config.experimental?.workflow?.world,
);
}

async rebuild(input: {
Expand All @@ -95,6 +114,13 @@ class TransactionalDevelopmentAuthoredRebuildCoordinator implements DevelopmentA

try {
nextHost = await prepareDevelopmentApplicationHost(previousHost.appRoot);
if (
usesParentDevelopmentWorkflowWorld(
nextHost.compileResult.manifest.config.experimental?.workflow?.world,
) !== this.#usesParentWorkflowWorld
) {
throw new DevelopmentWorkflowWorldChangeRequiresRestartError();
}
const nextHostFingerprint = await computeDevelopmentHostFingerprint(nextHost);
const nextRuntimeFingerprint = nextHost.generation.fingerprint;
const hasStructuralChange = nextHostFingerprint !== this.#currentHostFingerprint;
Expand Down
Loading
Loading