Skip to content

Commit 2d54f8d

Browse files
committed
feat(runtime): add mini cloudflare preview shim
1 parent ac5df5e commit 2d54f8d

11 files changed

Lines changed: 1357 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## [0.1.1](https://github.com/async-framework/async-web/releases/tag/v0.1.1) - 2026-06-08
4+
5+
### Features
6+
7+
- Added a Mini Cloudflare provider shim for local Worker-shaped deployments with ASSETS, KV, R2, D1, `ctx.waitUntil()`, `caches.default`, virtual preview URLs, and WebRuntime-backed execution.
8+
- Added a Node preview server helper for serving Mini Cloudflare deployments on real `127.0.0.1` URLs for browser and Tailscale iteration.
9+
10+
### Documentation
11+
12+
- Added a Mini Cloudflare guide that maps the local provider shim to Async Webapps build, deployment, preview, and future Cloudflare adapter flows.
13+
314
## [0.1.0](https://github.com/async-framework/async-web/releases/tag/v0.1.0) - 2026-05-28
415

516
Initial public release of Async Web as `@async/web`.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ pnpm build
136136
- [Routes and Cache](docs/routes-and-cache.md)
137137
- [Platform and Runtimes](docs/platform-and-runtimes.md)
138138
- [AsyncDB Integration](docs/async-db-integration.md)
139+
- [Mini Cloudflare](docs/mini-cloudflare.md)
139140
- [Vite Compile-Away](docs/vite-compile-away.md)
140141
- [Migration Guide](docs/migration-from-miniweb.md)
141142

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Async Web has three public import layers in one npm package:
1515
- [Routes and Cache](routes-and-cache.md)
1616
- [Platform and Runtimes](platform-and-runtimes.md)
1717
- [AsyncDB Integration](async-db-integration.md)
18+
- [Mini Cloudflare](mini-cloudflare.md)
1819
- [Vite Compile-Away](vite-compile-away.md)
1920
- [Migration Guide](migration-from-miniweb.md)
2021

docs/mini-cloudflare.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Mini Cloudflare
2+
3+
Mini Cloudflare is a local provider shim for exercising Cloudflare-shaped
4+
deployments through `@async/web/runtime`.
5+
6+
It is not a Cloudflare provisioner, not a Miniflare replacement, and it does not
7+
call the Cloudflare API. It gives product flows a deterministic local target
8+
that looks enough like Workers to validate deployment wiring before a real
9+
adapter exists.
10+
11+
Use it when you want to test:
12+
13+
- a generated preview URL for a deployment
14+
- a Worker-style `fetch(request, env, ctx)` handler
15+
- static assets through `env.ASSETS.fetch(request)`
16+
- KV, R2, and D1-like bindings with in-memory state
17+
- `ctx.waitUntil()` background work
18+
- `caches.default` behavior backed by WebRuntime edge cache
19+
- an actual `127.0.0.1` URL for browser or Tailscale preview testing
20+
21+
## Virtual Deployment
22+
23+
```ts
24+
import {
25+
createMiniCloudflareDeployment
26+
} from '@async/web/runtime/providers';
27+
28+
const deployment = await createMiniCloudflareDeployment({
29+
id: 'deployment_123',
30+
assets: {
31+
'/index.html': '<!doctype html><h1>Hello from preview</h1>'
32+
},
33+
kv: {
34+
KV: {
35+
feature: '{"enabled":true}'
36+
}
37+
},
38+
r2: {
39+
R2: {}
40+
},
41+
d1: {
42+
DB: {
43+
'select count(*) as count from deployments': {
44+
results: [{ count: 1 }]
45+
}
46+
}
47+
},
48+
worker: {
49+
async fetch(request, env, ctx) {
50+
const url = new URL(request.url);
51+
52+
if (url.pathname === '/api/status') {
53+
const feature = await env.KV.get('feature', 'json');
54+
const row = await env.DB
55+
.prepare('select count(*) as count from deployments')
56+
.first();
57+
58+
ctx.waitUntil(env.R2.put('events/api-status.json', JSON.stringify({
59+
pathname: url.pathname
60+
})));
61+
62+
return Response.json({
63+
feature,
64+
deploymentCount: row?.count
65+
});
66+
}
67+
68+
return env.ASSETS.fetch(request);
69+
}
70+
}
71+
});
72+
73+
console.log(deployment.previewUrl);
74+
// https://deployment-123.preview.async.local/
75+
76+
const response = await deployment.fetch('/');
77+
console.log(await response.text());
78+
```
79+
80+
## Local URL
81+
82+
Use the Node helper when a human needs a real browser URL:
83+
84+
```ts
85+
import {
86+
createMiniCloudflareDeployment
87+
} from '@async/web/runtime/providers';
88+
import {
89+
serveMiniCloudflareDeployment
90+
} from '@async/web/runtime/node/mini-cloudflare';
91+
92+
const deployment = await createMiniCloudflareDeployment({
93+
id: 'deployment_123',
94+
assets: {
95+
'/index.html': '<!doctype html><h1>Hello from localhost</h1>'
96+
}
97+
});
98+
99+
const server = await serveMiniCloudflareDeployment(deployment, {
100+
basePath: '/preview/deployment-123'
101+
});
102+
103+
console.log(server.url);
104+
// http://127.0.0.1:49152/preview/deployment-123/
105+
```
106+
107+
That URL can then be exposed with Tailscale Serve or another local sharing tool.
108+
109+
## Async Webapps Fit
110+
111+
The intended Async Webapps flow is:
112+
113+
```text
114+
build artifact
115+
-> @async/db deployment record
116+
-> mini Cloudflare deployment
117+
-> WebRuntime edge execution
118+
-> local preview URL
119+
-> optional Tailscale namespace
120+
```
121+
122+
The product dashboard should own builds, projects, releases, domains, and audit
123+
events. Mini Cloudflare should stay a local provider adapter that lets the
124+
dashboard prove the deployment contract before switching the same shape to real
125+
Cloudflare Workers, Pages, R2, KV, D1, or Hyperdrive-backed resources.

packages/web/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@async/web",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"type": "module",
55
"description": "Developer-facing Async app framework that lowers app conventions into WebRuntime configs.",
66
"license": "MIT",
@@ -62,6 +62,10 @@
6262
"types": "./dist/runtime/node/index.d.ts",
6363
"default": "./dist/runtime/node/index.js"
6464
},
65+
"./runtime/node/mini-cloudflare": {
66+
"types": "./dist/runtime/node/create-mini-cloudflare-preview-server.d.ts",
67+
"default": "./dist/runtime/node/create-mini-cloudflare-preview-server.js"
68+
},
6569
"./runtime/platform": {
6670
"types": "./dist/runtime/platform/index.d.ts",
6771
"default": "./dist/runtime/platform/index.js"

packages/webruntime/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
"types": "./dist/node/index.d.ts",
4040
"default": "./dist/node/index.js"
4141
},
42+
"./node/mini-cloudflare": {
43+
"types": "./dist/node/create-mini-cloudflare-preview-server.d.ts",
44+
"default": "./dist/node/create-mini-cloudflare-preview-server.js"
45+
},
4246
"./edge": {
4347
"types": "./dist/edge/index.d.ts",
4448
"default": "./dist/edge/index.js"
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import type { IncomingMessage, ServerResponse } from 'node:http';
2+
import { Buffer } from 'node:buffer';
3+
import { createHttpTestServer, type HttpTestServer } from './create-http-test-server.ts';
4+
import type { MiniCloudflareDeployment } from '../providers/mini-cloudflare.ts';
5+
6+
export interface MiniCloudflarePreviewServer {
7+
readonly origin: string;
8+
readonly url: string;
9+
readonly server: HttpTestServer['server'];
10+
close(): Promise<void>;
11+
}
12+
13+
export interface ServeMiniCloudflareDeploymentOptions {
14+
basePath?: string;
15+
}
16+
17+
export async function serveMiniCloudflareDeployment(
18+
deployment: MiniCloudflareDeployment,
19+
options: ServeMiniCloudflareDeploymentOptions = {}
20+
): Promise<MiniCloudflarePreviewServer> {
21+
const basePath = normalizeBasePath(options.basePath ?? '/');
22+
const server = await createHttpTestServer(async (request, response) => {
23+
try {
24+
await proxyToDeployment(deployment, basePath, request, response);
25+
} catch (error) {
26+
response.writeHead(500, {
27+
'content-type': 'text/plain; charset=utf-8'
28+
});
29+
response.end(error instanceof Error ? error.message : String(error));
30+
}
31+
});
32+
33+
return {
34+
origin: server.origin,
35+
url: `${server.origin}${basePath === '/' ? '/' : `${basePath}/`}`,
36+
server: server.server,
37+
close() {
38+
return server.close();
39+
}
40+
};
41+
}
42+
43+
async function proxyToDeployment(
44+
deployment: MiniCloudflareDeployment,
45+
basePath: string,
46+
incoming: IncomingMessage,
47+
outgoing: ServerResponse
48+
): Promise<void> {
49+
const method = incoming.method ?? 'GET';
50+
const incomingUrl = new URL(incoming.url ?? '/', 'http://localhost');
51+
if (basePath !== '/' && !matchesBasePath(incomingUrl.pathname, basePath)) {
52+
outgoing.writeHead(404, {
53+
'content-type': 'text/plain; charset=utf-8'
54+
});
55+
outgoing.end('Not Found');
56+
return;
57+
}
58+
59+
const target = new URL(stripBasePath(incomingUrl.pathname, basePath), deployment.origin);
60+
target.search = incomingUrl.search;
61+
const headers = headersFromIncoming(incoming);
62+
const body = method === 'GET' || method === 'HEAD'
63+
? undefined
64+
: await readIncomingBody(incoming);
65+
const request = new Request(target, {
66+
method,
67+
headers,
68+
body,
69+
duplex: body ? 'half' : undefined
70+
} as RequestInit & { duplex?: 'half' });
71+
72+
const response = await deployment.fetch(request);
73+
const responseHeaders = headersToOutgoing(response.headers);
74+
outgoing.writeHead(response.status, response.statusText, responseHeaders);
75+
const responseBody = Buffer.from(await response.arrayBuffer());
76+
outgoing.end(responseBody);
77+
}
78+
79+
function headersFromIncoming(request: IncomingMessage): Headers {
80+
const headers = new Headers();
81+
for (const [name, value] of Object.entries(request.headers)) {
82+
if (value === undefined) {
83+
continue;
84+
}
85+
if (Array.isArray(value)) {
86+
for (const item of value) {
87+
headers.append(name, item);
88+
}
89+
} else {
90+
headers.set(name, value);
91+
}
92+
}
93+
return headers;
94+
}
95+
96+
function headersToOutgoing(headers: Headers): Record<string, string[]> {
97+
const outgoing: Record<string, string[]> = {};
98+
for (const [name, value] of headers) {
99+
outgoing[name] = [...(outgoing[name] ?? []), value];
100+
}
101+
return outgoing;
102+
}
103+
104+
async function readIncomingBody(request: IncomingMessage): Promise<Buffer> {
105+
const chunks: Buffer[] = [];
106+
for await (const chunk of request) {
107+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
108+
}
109+
return Buffer.concat(chunks);
110+
}
111+
112+
function normalizeBasePath(value: string): string {
113+
const trimmed = value.trim();
114+
if (!trimmed || trimmed === '/') {
115+
return '/';
116+
}
117+
return `/${trimmed.replace(/^\/+|\/+$/g, '')}`;
118+
}
119+
120+
function matchesBasePath(pathname: string, basePath: string): boolean {
121+
return pathname === basePath || pathname.startsWith(`${basePath}/`);
122+
}
123+
124+
function stripBasePath(pathname: string, basePath: string): string {
125+
if (basePath === '/') {
126+
return pathname;
127+
}
128+
const stripped = pathname.slice(basePath.length);
129+
return stripped.startsWith('/') ? stripped : `/${stripped}`;
130+
}

packages/webruntime/src/node/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './create-node-web-runtime.ts';
22
export * from './create-node-frontend.ts';
33
export * from './create-node-service-worker.ts';
44
export * from './create-http-test-server.ts';
5+
export * from './create-mini-cloudflare-preview-server.ts';

packages/webruntime/src/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@ export function defineProvider(provider: WebRuntimeProviderDefinition): WebRunti
3535
return provider;
3636
}
3737

38+
export * from './mini-cloudflare.ts';

0 commit comments

Comments
 (0)