Skip to content

Commit

Permalink
fix: increase max duration to 5 minutes for rest endpoints (#1177)
Browse files Browse the repository at this point in the history
  • Loading branch information
abvthecity authored Jul 23, 2024
1 parent 8553162 commit 4d65b9c
Show file tree
Hide file tree
Showing 6 changed files with 127 additions and 90 deletions.
56 changes: 34 additions & 22 deletions packages/ui/app/src/api-playground/types/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,39 @@
import { JsonVariant } from "./jsonVariant";
import { SerializableFile, SerializableFormDataEntryValue } from "./serializable";
import { z } from "zod";
import { SerializableFileSchema, SerializableFormDataEntryValueSchema, SerializableJsonSchema } from "./serializable";

export const SerializableFormDataSchema = z.object({
type: z.literal("form-data"),
value: z.record(SerializableFormDataEntryValueSchema),
});

export type SerializableFormData = z.infer<typeof SerializableFormDataSchema>;

export const SerializableOctetStreamSchema = z.object({
type: z.literal("octet-stream"),
value: SerializableFileSchema.optional(),
});

export const SerializableBodySchema = z.union([
SerializableJsonSchema,
SerializableFormDataSchema,
SerializableOctetStreamSchema,
]);

export const ProxyRequestSchema = z.object({
url: z.string(),
method: z.string(),
headers: z.record(z.string()),
body: SerializableBodySchema.optional(),
stream: z.boolean().optional(),
streamTerminator: z.string().optional(),
});

export type ProxyRequest = z.infer<typeof ProxyRequestSchema>;

export declare namespace ProxyRequest {
export interface SerializableFormData {
type: "form-data";
value: Record<string, SerializableFormDataEntryValue>;
}

export interface SerializableOctetStream {
type: "octet-stream";
value: SerializableFile | undefined;
}

export type SerializableBody = JsonVariant | SerializableFormData | SerializableOctetStream;
}

export interface ProxyRequest {
url: string;
method: string;
headers: Record<string, string>;
body: ProxyRequest.SerializableBody | undefined;
stream?: boolean;
streamTerminator?: string;
export type SerializableOctetStream = z.infer<typeof SerializableOctetStreamSchema>;
export type SerializableBody = z.infer<typeof SerializableBodySchema>;
export type ProxyRequest = z.infer<typeof ProxyRequestSchema>;
}

export declare namespace ProxyResponse {
Expand Down
64 changes: 37 additions & 27 deletions packages/ui/app/src/api-playground/types/serializable.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,42 @@
export interface SerializableFile {
readonly name: string;
readonly lastModified: number;
readonly size: number;
readonly type: string;
readonly dataUrl: string; // base64-encoded
}
import { z } from "zod";

export declare namespace SerializableFormDataEntryValue {
interface SingleFile {
type: "file";
value: SerializableFile | undefined;
}
export const SerializableFileSchema = z
.object({
name: z.string(),
lastModified: z.number(),
size: z.number(),
type: z.string(),
dataUrl: z.string({ description: "base64-encoded" }),
})
.readonly();

interface MultipleFiles {
type: "fileArray";
value: SerializableFile[];
}
export type SerializableFile = z.infer<typeof SerializableFileSchema>;

interface Json {
type: "json";
value: unknown;
export const SerializableSingleFileSchema = z.object({
type: z.literal("file"),
value: SerializableFileSchema.optional(),
});

// if contentType is not provided, assume stringified JSON. Otherwise, use the provided contentType as a Blob type
contentType: string | undefined;
}
}
export const SerializableMultipleFilesSchema = z.object({
type: z.literal("fileArray"),
value: z.array(SerializableFileSchema),
});

export type SerializableFormDataEntryValue =
| SerializableFormDataEntryValue.Json
| SerializableFormDataEntryValue.SingleFile
| SerializableFormDataEntryValue.MultipleFiles;
export const SerializableJsonSchema = z.object({
type: z.literal("json"),
value: z.unknown(),
contentType: z
.string({
description:
"if contentType is not provided, assume stringified JSON. Otherwise, use the provided contentType as a Blob type",
})
.optional(),
});

export const SerializableFormDataEntryValueSchema = z.union([
SerializableSingleFileSchema,
SerializableMultipleFilesSchema,
SerializableJsonSchema,
]);

export type SerializableFormDataEntryValue = z.infer<typeof SerializableFormDataEntryValueSchema>;
1 change: 1 addition & 0 deletions packages/ui/app/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { type CustomerAnalytics } from "./analytics/types";
export { Stream } from "./api-playground/Stream";
export { ProxyRequestSchema } from "./api-playground/types";
export type { ProxyRequest, ProxyResponse } from "./api-playground/types";
export { DEFAULT_FEATURE_FLAGS } from "./atoms";
export type { DocsProps, FeatureFlags } from "./atoms";
Expand Down
13 changes: 10 additions & 3 deletions packages/ui/docs-bundle/src/pages/api/fern-docs/proxy/file.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { ProxyRequest } from "@fern-ui/ui";
import { ProxyRequestSchema } from "@fern-ui/ui";
import { NextRequest, NextResponse } from "next/server";
import { buildRequestBody } from "./rest";

/**
* Note: edge functions must return a response within 25 seconds.
*
* This function is used to return the response directly from the proxied request, and is useful for file downloads.
*
* TODO: this should be rewritten as a node.js serverless function to avoid the 25 second limit, since file downloads can take a much longer time.
*/

export const runtime = "edge";
export const dynamic = "force-dynamic";
export const maxDuration = 60 * 5; // 5 minutes

export default async function POST(req: NextRequest): Promise<NextResponse<null | Uint8Array>> {
if (req.method !== "POST" && req.method !== "OPTIONS") {
Expand All @@ -27,7 +34,7 @@ export default async function POST(req: NextRequest): Promise<NextResponse<null
}

try {
const proxyRequest = (await req.json()) as ProxyRequest;
const proxyRequest = ProxyRequestSchema.parse(await req.json());
const requestBody = await buildRequestBody(proxyRequest.body);
const headers = new Headers(proxyRequest.headers);

Expand Down
74 changes: 39 additions & 35 deletions packages/ui/docs-bundle/src/pages/api/fern-docs/proxy/rest.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { assertNever } from "@fern-ui/core-utils";
import type { ProxyRequest, ProxyResponse } from "@fern-ui/ui";
import { unknownToString } from "@fern-ui/ui";
import { NextResponse, type NextRequest } from "next/server";

export const runtime = "edge";
export const dynamic = "force-dynamic";
export const maxDuration = 60 * 5; // 5 minutes
import type { ProxyRequest } from "@fern-ui/ui";
import { ProxyRequestSchema, unknownToString } from "@fern-ui/ui";
import type { NextApiRequest, NextApiResponse } from "next/types";

/**
* Note: this API route must be deployed as an node.js serverless function because
* edge functions must return a response within 25 seconds.
*
* NodeJS serverless functions can have a maximum execution time of 5 minutes.
*
* TODO: this is kind of expensive to run as a serverless function in vercel. We should consider moving this to cloudflare workers.
*/

export const config = {
maxDuration: 60 * 5, // 5 minutes
};

async function dataURLtoBlob(dataUrl: string): Promise<Blob> {
if (dataUrl.startsWith("http:") || dataUrl.startsWith("https:")) {
Expand Down Expand Up @@ -108,31 +117,29 @@ export async function buildRequestBody(body: ProxyRequest.SerializableBody | und
}
}

export default async function POST(req: NextRequest): Promise<NextResponse<null | ProxyResponse>> {
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
if (req.method !== "POST" && req.method !== "OPTIONS") {
return new NextResponse(null, { status: 405 });
return res.status(405).send(null);
}

const origin = req.headers.get("Origin");
const origin = req.headers.origin;
if (origin == null) {
return new NextResponse(null, { status: 400 });
return res.status(400).send(null);
}

const corsHeaders = {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "POST",
"Access-Control-Allow-Headers": "Content-Type",
};
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Methods", "POST");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");

if (req.method === "OPTIONS") {
return new NextResponse(null, { status: 204, headers: corsHeaders });
return res.status(204).send(null);
}

// eslint-disable-next-line no-console
console.log("Starting proxy request to", req.url);

try {
const proxyRequest = (await req.json()) as ProxyRequest;
const proxyRequest = ProxyRequestSchema.parse(req.body);
const requestBody = await buildRequestBody(proxyRequest.body);
const headers = new Headers(proxyRequest.headers);

Expand Down Expand Up @@ -169,26 +176,23 @@ export default async function POST(req: NextRequest): Promise<NextResponse<null
}
const responseHeaders = response.headers;

return NextResponse.json<ProxyResponse>(
{
response: {
headers: Object.fromEntries(responseHeaders.entries()),
ok: response.ok,
redirected: response.redirected,
status: response.status,
statusText: response.statusText,
type: response.type,
url: response.url,
body,
},
time: endTime - startTime,
size: responseHeaders.get("Content-Length"),
res.status(200).json({
response: {
headers: Object.fromEntries(responseHeaders.entries()),
ok: response.ok,
redirected: response.redirected,
status: response.status,
statusText: response.statusText,
type: response.type,
url: response.url,
body,
},
{ status: 200, headers: corsHeaders },
);
time: endTime - startTime,
size: responseHeaders.get("Content-Length"),
});
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
return new NextResponse(null, { status: 500, headers: corsHeaders });
return res.status(500).send(null);
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { ProxyRequest } from "@fern-ui/ui";
import { ProxyRequestSchema } from "@fern-ui/ui";
import { NextRequest, NextResponse } from "next/server";

/**
* Note: edge functions must return a response within 25 seconds.
*/

export const runtime = "edge";
export const dynamic = "force-dynamic";
export const maxDuration = 60 * 5; // 5 minutes
export const supportsResponseStreaming = true;

export default async function POST(req: NextRequest): Promise<NextResponse<null | Uint8Array>> {
Expand All @@ -26,7 +29,7 @@ export default async function POST(req: NextRequest): Promise<NextResponse<null
return new NextResponse(null, { status: 204, headers: corsHeaders });
}
try {
const proxyRequest = (await req.json()) as ProxyRequest;
const proxyRequest = ProxyRequestSchema.parse(await req.json());
const startTime = Date.now();
const response = await fetch(proxyRequest.url, {
method: proxyRequest.method,
Expand Down

0 comments on commit 4d65b9c

Please sign in to comment.