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
2 changes: 1 addition & 1 deletion src/ai/workflow/blob/gcs-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export class GCSBlobStorage implements BlobStorage {
const response = await fetch(uploadUrl, {
method: "POST",
headers,
body: body,
body: body as BodyInit,
});

if (!response.ok) {
Expand Down
5 changes: 4 additions & 1 deletion src/cli/commands/doctor/server-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,10 @@ export async function checkRSCEndpoints(): Promise<DiagnosticResult[]> {
* Check RSC counters snapshot (metrics endpoint)
*/
export async function checkRSCCounters(): Promise<DiagnosticResult> {
let met: Response | null = null;
try {
const base = new URL("http://127.0.0.1:3000/");
const met = await fetch(new URL("/_metrics", base)).catch(() => null);
met = await fetch(new URL("/_metrics", base)).catch(() => null);
if (met?.ok) {
const j = (await met.json().catch(() => null)) as any;
const c = j && (j as any).counters ? (j as any).counters : {};
Expand All @@ -120,6 +121,7 @@ export async function checkRSCCounters(): Promise<DiagnosticResult> {
} action:${c.rscAction ?? 0} errors:${c.rscErrors ?? 0}`;

Copilot AI Dec 9, 2025

Copy link

Choose a reason for hiding this comment

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

Missing safeCancelBody(met) call in the success path. The response body should be cancelled after consuming it with met.json() to prevent resource leaks, similar to how it's done in checkRSCEndpoints() for the manifest and stream responses (lines 71, 89). Add await safeCancelBody(met); before the return statement on line 122.

Suggested change
} action:${c.rscAction ?? 0} errors:${c.rscErrors ?? 0}`;
} action:${c.rscAction ?? 0} errors:${c.rscErrors ?? 0}`;
await safeCancelBody(met);

Copilot uses AI. Check for mistakes.
return { name: "RSC Counters", status: "pass", message: msg };
} else {
await safeCancelBody(met);
return {
name: "RSC Counters",
status: "warn",
Expand All @@ -128,6 +130,7 @@ export async function checkRSCCounters(): Promise<DiagnosticResult> {
}
} catch (error) {
cliLogger.debug("Failed to check RSC counters:", error);
await safeCancelBody(met);
return {
name: "RSC Counters",
status: "warn",
Expand Down
3 changes: 2 additions & 1 deletion src/rendering/component-handling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,10 @@ export async function handleComponentPage(

// Get project's React for createElement to ensure element symbols match user components
const React = await getProjectReact();
const componentProps = options?.props || {};
const pageElement = React.createElement(
PageComponent,
options?.props || {},
componentProps,
) as BundledReact.ReactElement;

const pageBundle: PageBundle = {
Expand Down
19 changes: 14 additions & 5 deletions src/rendering/orchestrator/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,19 +84,28 @@ export class RenderPipeline {
if (options?.request && options?.url) {
try {
if (!options.params || Object.keys(options.params).length === 0) {
logger.info("[renderPage] Attempting to extract Pages Router params", {
logger.info("[renderPage] Attempting to extract route params", {
slug,
pageId: pageInfo.entity.id,
});

try {
const params: Record<string, string | string[]> = {};
const pagesIndex = pageInfo.entity.id.indexOf("/pages/");
const appIndex = pageInfo.entity.id.indexOf("/app/");

// Determine the base path for param extraction
let relativePath: string | null = null;
if (pagesIndex !== -1) {
const relativePath = pageInfo.entity.id.substring(pagesIndex + 7); // Skip "/pages/"
relativePath = pageInfo.entity.id.substring(pagesIndex + 7); // Skip "/pages/"
} else if (appIndex !== -1) {
relativePath = pageInfo.entity.id.substring(appIndex + 5); // Skip "/app/"
}

if (relativePath) {
const pathSegments = relativePath.split("/").map((s) =>
s.replace(/\.(tsx|jsx|ts|js|mdx)$/, "")
);
).filter((s) => s !== "page" && s !== "route"); // Exclude App Router file names
const slugSegments = slug.split("/").filter(Boolean);

for (let i = 0; i < pathSegments.length && i < slugSegments.length; i++) {
Expand Down Expand Up @@ -127,13 +136,13 @@ export class RenderPipeline {

if (Object.keys(params).length > 0) {
options.params = params;
logger.info("[renderPage] Extracted Pages Router params", {
logger.info("[renderPage] Extracted route params", {
slug,
params,
});
}
} catch (paramError) {
logger.error("[renderPage] Failed to extract Pages Router params", {
logger.error("[renderPage] Failed to extract route params", {
slug,
error: paramError instanceof Error ? paramError.message : String(paramError),
stack: paramError instanceof Error ? paramError.stack : undefined,
Expand Down
62 changes: 61 additions & 1 deletion src/rendering/page-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,60 @@ export class PageRenderer {
}
}

/**
* Extract route params from path and slug (fallback extraction)
* Handles both App Router (/app/) and Pages Router (/pages/) patterns
*/
private extractParamsFromPath(
pageEntityId: string,
slug: string,
): Record<string, string | string[]> | undefined {
const params: Record<string, string | string[]> = {};

// Find router base path
const appIndex = pageEntityId.indexOf("/app/");
const pagesIndex = pageEntityId.indexOf("/pages/");

let relativePath: string | null = null;
if (appIndex !== -1) {
relativePath = pageEntityId.substring(appIndex + 5); // Skip "/app/"
} else if (pagesIndex !== -1) {
relativePath = pageEntityId.substring(pagesIndex + 7); // Skip "/pages/"
}

if (!relativePath) {
return undefined;
}

// Extract path segments, removing file extensions and App Router file names
const pathSegments = relativePath
.split("/")
.map((s) => s.replace(/\.(tsx|jsx|ts|js|mdx)$/, ""))
.filter((s) => s !== "page" && s !== "route" && s.length > 0);

const slugSegments = slug.split("/").filter(Boolean);

// Match dynamic segments with slug values
for (let i = 0; i < pathSegments.length && i < slugSegments.length; i++) {
const pathSeg = pathSegments[i];
const slugSeg = slugSegments[i];

if (pathSeg && pathSeg.startsWith("[") && pathSeg.endsWith("]")) {
const isCatchAll = pathSeg.startsWith("[...");
const paramName = pathSeg.replace(/\[\.\.\.|\[|\]/g, "");

if (isCatchAll) {
params[paramName] = slugSegments.slice(i);
break;
} else if (slugSeg !== undefined) {
params[paramName] = slugSeg;
}
}
}

return Object.keys(params).length > 0 ? params : undefined;
}

/**
* Prepare page bundles based on file type
* Handles MDX, TSX/JSX components, and TS/JS scripts
Expand Down Expand Up @@ -148,10 +202,16 @@ export class PageRenderer {
// Dispatch to appropriate handler based on page type
switch (pageType.type) {
case "component": {
// Extract params from path if not provided (fallback extraction)
let params = options?.params;
if (!params || Object.keys(params).length === 0) {
params = this.extractParamsFromPath(pageInfo.entity.id, slug);
}

// For App Router pages, params should be passed as props
const componentProps = {
...options?.props,
...(options?.params ? { params: options.params } : {}),
...(params && Object.keys(params).length > 0 ? { params } : {}),
};

const result = await handleComponentPage(
Expand Down
4 changes: 2 additions & 2 deletions src/server/handlers/request/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ export class StaticHandler extends BaseHandler {
const builder = this.createResponseBuilder(ctx);

// For HEAD requests, don't include body
// Cast to Uint8Array to satisfy BodyInit type in newer TypeScript
const body = req.method.toUpperCase() === "HEAD" ? null : fileData as Uint8Array;
// Cast to BodyInit to satisfy type in newer TypeScript/Deno versions
const body = req.method.toUpperCase() === "HEAD" ? null : fileData as BodyInit;

const response = builder
.withCORS(req, ctx.securityConfig?.cors)
Expand Down
30 changes: 23 additions & 7 deletions tests/_helpers/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export class TestContext {
private readonly testName: string;
private tempDir?: string;
private servers: TestServer[] = [];
private serverControllers: AbortController[] = [];
private allocatedPorts: number[] = [];
private originalEnv: Map<string, string | undefined> = new Map();
private originalDisableLru?: string;
Expand Down Expand Up @@ -217,10 +218,15 @@ export class TestContext {
const port = options.port || (await this.allocatePort());
const hostname = options.hostname || "127.0.0.1";

// Create AbortController for proper cleanup
const controller = new AbortController();
this.serverControllers.push(controller);

const server = await startProductionServer({
projectDir: this.projectDir,
port,
hostname,
signal: controller.signal,
});

// Add to tracked servers
Expand Down Expand Up @@ -277,16 +283,29 @@ export class TestContext {
}
}

// Abort all server controllers first to signal shutdown
for (const controller of this.serverControllers) {
try {
controller.abort();
} catch {
// Ignore abort errors - server may already be stopped
}
}

// Stop all servers
for (const server of this.servers) {
try {
await server.stop();
await this.waitForServerStopped(server);
} catch (error) {
errors.push(error as Error);
} catch {
// Ignore stop errors - server may already be stopped
}
}

// Clear the arrays to prevent double cleanup
this.serverControllers.length = 0;
this.servers.length = 0;

// Clean up renderers and caches to prevent resource leaks
try {
const { cleanupBundler } = await import("../../src/rendering/cleanup.ts");
Expand Down Expand Up @@ -483,11 +502,8 @@ export class TestContext {
const response = await fetch(url, { signal: AbortSignal.timeout(100) });
// Consume the response body
await response.body?.cancel();
// If fetch succeeds, server is still running
await new Promise<void>((resolve) => {
const timeoutId = setTimeout(resolve, 100);
Promise.resolve().then(() => clearTimeout(timeoutId));
});
// If fetch succeeds, server is still running, wait before next attempt
await new Promise<void>((resolve) => setTimeout(resolve, 100));
} catch {
// Server has stopped
return;
Expand Down
Loading