跳转到内容
本站为社区维护的非官方中文翻译,与 Cloudflare, Inc. 无关联。本站不提供账户登录,请勿在任何非官方站点输入您的 Cloudflare 账户信息。官方文档:developers.cloudflare.com

预览 Web 应用

最后更新 查看 MarkdownAgent 设置

使用浏览器打开 sandbox 中运行的 Web server。该 server 没有公网端口,因此由 Worker 将请求转发给它。本例中,Worker 会将 /previews/<NAME>/ 下的每个请求转发到名为 <NAME> 的 sandbox。

前提条件

将请求转发到 server

  1. 选择启动 server 的命令。本例中,该命令运行一个小型 Node.js server,返回请求路径和 server 运行时间。请将其替换为你自己的命令,例如 npm run dev:

    src/index.jsjs
    const port = 8080;
    
    const server = `
    const { createServer } = require("node:http");
    
    process.on("SIGTERM", () => process.exit(0));
    
    createServer((request, response) => {
        response.setHeader("Content-Type", "application/json");
        response.end(
            JSON.stringify({ path: request.url, uptime: Math.round(process.uptime()) }),
        );
    }).listen(8080, "0.0.0.0");
    `;
    src/index.tsts
    const port = 8080;
    
    const server = `
    const { createServer } = require("node:http");
    
    process.on("SIGTERM", () => process.exit(0));
    
    createServer((request, response) => {
        response.setHeader("Content-Type", "application/json");
        response.end(
            JSON.stringify({ path: request.url, uptime: Math.round(process.uptime()) }),
        );
    }).listen(8080, "0.0.0.0");
    `;

    server 必须监听 0.0.0.0,因为 getTcpPort() 无法到达只监听 127.0.0.1 的 server。许多开发 server 默认监听 localhost,并提供更改地址的选项。该 server 作为主进程运行。当它退出时(例如崩溃后),实例会停止,其文件也会随之结束。如果 sandbox 中的命令会更改你需要的文件,请改为按 在后台运行 server 中的说明启动 server。

    部署后,基于 Python http.server 模块的 server(例如 python3 -m http.server)会在启动时退出,因为它们会查找 container hostname。对于提供文件的 Python 命令,请参阅 部署后的差异。

  2. 为 Durable Object 添加不活动超时。当重启后的 Durable Object 发现 container 仍在运行时,构造函数会再次设置超时:

    src/index.tsts
    const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
    
    export class MyContainer extends DurableObject<Env> {
        constructor(ctx: DurableObjectState, env: Env) {
            super(ctx, env);
            const container = ctx.container;
            if (container?.running) {
                void ctx.blockConcurrencyWhile(() =>
                    container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS),
                );
            }
        }
    }

    五分钟不活动超时会在访问之间让 server 保持运行。更多信息请参阅 Sandbox 生命周期。

    然后启动 server 并等到它响应:

    src/index.tsts
    export class MyContainer extends DurableObject<Env> {
        // ...
    
        // Skip the check after the first response
        private ready = false;
    
        private async startServer(): Promise<Container> {
            const container = this.ctx.container;
    
            if (!container) {
                throw new Error("No container is configured");
            }
    
            if (!container.running) {
                container.start({
                    image: "cloudflare/debian-trixie",
                    entrypoint: ["node", "--eval", server],
                    enableInternet: false,
                });
                await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS);
                this.ready = false;
            }
    
            // `start()` returns before the server listens. Send `GET /` until any
            // HTTP response arrives, for up to 30 seconds
            const deadline = Date.now() + 30_000;
    
            while (!this.ready && Date.now() < deadline) {
                try {
                    const response = await container
                        .getTcpPort(port)
                        .fetch("http://container/", { signal: AbortSignal.timeout(1_000) });
                    await response.body?.cancel();
                    this.ready = true;
                } catch {
                    await scheduler.wait(500);
                }
            }
    
            return container;
        }
    }
  3. 从 Durable Object 的 fetch() handler 转发请求:

    src/index.tsts
    export class MyContainer extends DurableObject<Env> {
        // ...
    
        async fetch(request: Request): Promise<Response> {
            const container = await this.startServer();
    
            if (!this.ready) {
                return new Response("The preview did not start", { status: 503 });
            }
    
            // Forward each request once, because a retry could send a `POST` twice
            try {
                return await container.getTcpPort(port).fetch(request);
            } catch {
                // Check the server again on the next request
                this.ready = false;
                return new Response("The preview is not responding", { status: 502 });
            }
        }
    }
  4. 将预览路径从 Worker 路由到 Durable Object:

    src/index.jsjs
    function forward(sandbox, request, path) {
    	const url = new URL(request.url);
    
    	if (!path) {
    		url.pathname += "/";
    		return Response.redirect(url.toString(), 308);
    	}
    
    	// Prefix the host, so that a path such as //example.com/ stays a path.
    	const target = new URL(`http://container${path}${url.search}`);
    	return sandbox.fetch(new Request(target, request));
    }
    
    export default {
    	async fetch(request, env) {
    		const url = new URL(request.url);
    		const preview =
    			/^\/previews\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\/.*)?$/.exec(
    				url.pathname,
    			);
    
    		if (!preview) {
    			return new Response("Not found", { status: 404 });
    		}
    
    		const sandbox = env.MY_CONTAINER.getByName(preview[1]);
    		return forward(sandbox, request, preview[2]);
    	},
    };
    src/index.tsts
    function forward(
        sandbox: DurableObjectStub<MyContainer>,
        request: Request,
        path: string | undefined,
    ): Promise<Response> | Response {
        const url = new URL(request.url);
    
        if (!path) {
            url.pathname += "/";
            return Response.redirect(url.toString(), 308);
        }
    
        // Prefix the host, so that a path such as //example.com/ stays a path.
        const target = new URL(`http://container${path}${url.search}`);
        return sandbox.fetch(new Request(target, request));
    }
    
    export default {
        async fetch(request: Request, env: Env): Promise<Response> {
            const url = new URL(request.url);
            const preview = /^\/previews\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\/.*)?$/.exec(url.pathname);
    
            if (!preview) {
                return new Response("Not found", { status: 404 });
            }
    
            const sandbox = env.MY_CONTAINER.getByName(preview[1]);
            return forward(sandbox, request, preview[2]);
        },
    } satisfies ExportedHandler<Env>;

    server 会收到没有 /previews/<NAME> 前缀的每条路径。以 / 开头的链接会离开预览,因此请使用相对链接,或 在独立 hostname 上提供每个预览。

    本例中,路由从 URL 读取 sandbox 名称。该模式只接受有效 DNS 标签的名称:1 到 63 个小写字母、数字和连字符,两端不能是连字符。此路由接受的名称也可以是 预览 hostname。编码后的 / 不会匹配,因此此路由无法到达另一条路由用 / 命名的 sandbox,例如 <OWNER>/<SESSION>。

    在调用方到达预览之前请先认证。从不以你的应用为宿主的 hostname 提供预览。更多信息请参阅 Sandbox 安全。

  5. 部署你的 Worker:

    npx wrangler deploy
  6. 从名为 ada 的预览请求页面。将示例 hostname 替换为 Wrangler 打印的 workers.dev URL:

    curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/previews/ada/hello
    { "path": "/hello", "uptime": 0 }

    等待几秒后再次发送相同请求。运行时间会增加,因为同一个 server 处理这两个请求:

    { "path": "/hello", "uptime": 5 }

保持 WebSocket 连接打开

上一节中的 fetch() handler 会将 WebSocket 升级请求传给 server,因此 WebSocket 连接可以工作。只经过 Durable Object 的连接不会让 sandbox 保持运行。不活动超时结束时,Cloudflare 会停止实例,连接会关闭。

要在页面打开连接时让 sandbox 保持运行,请在 Durable Object 中接受连接的两端:

  1. 添加在访问者和 server 之间中继消息的函数:

    src/index.jsjs
    function bridge(response) {
    	const upstream = response.webSocket;
    	const [client, server] = Object.values(new WebSocketPair());
    
    	// Receive binary messages as `ArrayBuffer` instead of `Blob`
    	upstream.binaryType = "arraybuffer";
    	server.binaryType = "arraybuffer";
    	upstream.accept();
    	server.accept();
    
    	// A listener that throws drops the connection, so these never throw.
    	const relay = (from, to) => {
    		from.addEventListener("message", (event) => {
    			try {
    				to.send(event.data);
    			} catch {
    				from.close(1011, "The other side closed");
    			}
    		});
    		from.addEventListener("close", (event) => {
    			// 1005 and 1006 report a missing or abnormal close and cannot be sent.
    			const code =
    				event.code === 1005 || event.code === 1006 ? 1000 : event.code;
    			try {
    				to.close(code, event.reason);
    			} catch {}
    		});
    	};
    
    	relay(server, upstream);
    	relay(upstream, server);
    
    	// Pass on the subprotocol that the server chose. A browser closes a
    	// connection that requested one and receives none.
    	const headers = new Headers();
    	const protocol = response.headers.get("Sec-WebSocket-Protocol");
    	if (protocol) {
    		headers.set("Sec-WebSocket-Protocol", protocol);
    	}
    
    	return new Response(null, { status: 101, webSocket: client, headers });
    }
    src/index.tsts
    function bridge(response: Response): Response {
        const upstream = response.webSocket!;
        const [client, server] = Object.values(new WebSocketPair());
    
        // Receive binary messages as `ArrayBuffer` instead of `Blob`
        upstream.binaryType = "arraybuffer";
        server.binaryType = "arraybuffer";
        upstream.accept();
        server.accept();
    
        // A listener that throws drops the connection, so these never throw.
        const relay = (from: WebSocket, to: WebSocket) => {
            from.addEventListener("message", (event) => {
                try {
                    to.send(event.data);
                } catch {
                    from.close(1011, "The other side closed");
                }
            });
            from.addEventListener("close", (event) => {
                // 1005 and 1006 report a missing or abnormal close and cannot be sent.
                const code =
                    event.code === 1005 || event.code === 1006 ? 1000 : event.code;
                try {
                    to.close(code, event.reason);
                } catch {}
            });
        };
    
        relay(server, upstream);
        relay(upstream, server);
    
        // Pass on the subprotocol that the server chose. A browser closes a
        // connection that requested one and receives none.
        const headers = new Headers();
        const protocol = response.headers.get("Sec-WebSocket-Protocol");
        if (protocol) {
            headers.set("Sec-WebSocket-Protocol", protocol);
        }
    
        return new Response(null, { status: 101, webSocket: client, headers });
    }
  2. 在 Durable Object 的 fetch() handler 中,桥接打开 WebSocket 的响应:

    src/index.tsts
    try {
        const response = await container.getTcpPort(port).fetch(request);
        return response.webSocket ? bridge(response) : response;
    } catch {

有了桥接,只要任何页面有打开的连接,sandbox 就会保持运行,即使没有消息到达。最后一个连接关闭后,不活动超时会再次生效。

在开发 server 中使用热重载

Vite ↗ 等开发 server 会通过 WebSocket 重载页面。使用上一节的桥接时,重载连接会到达 server。两个 server 设置决定热重载是否工作:

  • server 会检查每个请求的 Host header。Vite 会拒绝它不认识的 hostname,因此请将预览 hostname 添加到 server.allowedHosts ↗。在 /previews/<NAME>/ 下,请求带着 Host header container 到达。
  • server 会从其 base path 构建资源绝对 URL 和重载连接。在 /previews/<NAME>/ 下,这些 URL 会离开预览。请 在独立 hostname 上提供每个预览,让路径原样到达 server。

以下 Vite 配置接受 example-previews.com 下独立 hostname 上的预览请求:

vite.config.jsjs
import { defineConfig } from "vite";

export default defineConfig({
    server: {
        host: "0.0.0.0",
        port: 8080,
        allowedHosts: [".example-previews.com"],
    },
});

用链接分享预览

让某人在有限时间内访问一个预览。本例中,Durable Object 存储带过期时间的随机 token。Worker 仅在 token 有效时转发 /shared/<NAME>/<TOKEN>/ 下的请求。

  1. 向 Durable Object 添加创建和检查 token 的方法:

    src/index.tsts
    export class MyContainer extends DurableObject<Env> {
        // ...
    
        async share(ttl: number): Promise<string> {
            const token = crypto.randomUUID();
            await this.ctx.storage.put(`share:${token}`, Date.now() + ttl);
            return token;
        }
    
        async isShared(token: string): Promise<boolean> {
            const key = `share:${token}`;
            const expires = await this.ctx.storage.get<number>(key);
    
            if (expires !== undefined && expires <= Date.now()) {
                await this.ctx.storage.delete(key);
                return false;
            }
    
            return expires !== undefined;
        }
    }
  2. 在 Worker 的 fetch() handler 中,在 404 响应之前添加创建链接和提供共享预览的路由:

    src/index.tsts
    const share = /^\/shares\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/.exec(url.pathname);
    
    if (share && request.method === "POST") {
        const sandbox = env.MY_CONTAINER.getByName(share[1]);
        const token = await sandbox.share(60 * 60 * 1000);
        return Response.json({ url: `${url.origin}/shared/${share[1]}/${token}/` });
    }
    
    const shared = /^\/shared\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/([^/]+)(\/.*)?$/.exec(url.pathname);
    
    if (shared) {
        const sandbox = env.MY_CONTAINER.getByName(shared[1]);
    
        if (!(await sandbox.isShared(shared[2]))) {
            return new Response("Not found", { status: 404 });
        }
    
        return forward(sandbox, request, shared[3]);
    }

    请认证 POST 请求,以便只有预览所有者可以创建指向它的链接。预览中的代码运行在此 hostname 上,可以用访问者的 cookie 向此路由发送请求。为防止这种情况,请从另一个 hostname 提供该路由,或使用浏览器中的页面无法发送的凭据进行认证,例如应用后端添加的 secret。

    共享路由不需要认证,因为 token 就是权限。

  3. 部署 Worker,然后创建指向名为 ada 的预览的链接:

    curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/shares/ada --request POST
    {
        "url": "https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/shared/ada/<TOKEN>/"
    }

    在浏览器中打开该 URL。预览会加载一小时。之后,以及对于任何其他 token 或预览名称,Worker 会以 404 响应。

任何拥有该链接的人都可以打开预览,直到它过期。每个 token 属于一个预览名称。要提前撤销链接,请从其 Durable Object 存储中删除其键。

停止预览

当不再需要预览时停止 server,而不是等待不活动超时:

  1. 向 Durable Object 添加停止 container 的方法:

    src/index.tsts
    export class MyContainer extends DurableObject<Env> {
        // ...
    
        async stop(): Promise<void> {
            if (this.ctx.container?.running) {
                await this.ctx.container.destroy();
            }
    
            this.ready = false;
        }
    }
  2. 在 Worker 中,对 DELETE /previews/<NAME> 调用它,在转发之前:

    src/index.tsts
    if (!preview[2] && request.method === "DELETE") {
        await sandbox.stop();
        return new Response(null, { status: 204 });
    }
  3. 部署 Worker,并停止名为 ada 的预览:

    curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/previews/ada --request DELETE

    Worker 会以 204 响应。对预览的下一个请求会启动新 server,运行时间为 0。

相关资源

这篇文档对您有帮助吗?