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
10 changes: 10 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Changes to CI, release scripts, and dependency manifests need a review from
# the Void team. Later rules take precedence over earlier ones.

/.github/ @voidzero-dev/void
# Script folders at any depth: the publish and deploy jobs run package build
# scripts such as packages/void/scripts/ and packages/platform/scripts/.
scripts/ @voidzero-dev/void
/pnpm-workspace.yaml @voidzero-dev/void
/pnpm-lock.yaml @voidzero-dev/void
package.json @voidzero-dev/void
2 changes: 1 addition & 1 deletion packages/void/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ production functions and real generated entries rather than copied code.
- **Self-host ISR requires a user-declared `ISR_CACHE` KV.** There is no `id: 'local'` injection for it: a placeholder id is fine for miniflare-backed dev but breaks a real `wrangler deploy`, which rejects an unknown namespace id. The user creates the namespace and binds it as `ISR_CACHE`; `readIsrCache`/`writeIsrCache` fail open to a live render when it is absent.
- **Workers AI has no local emulator.** For a saved direct Cloudflare destination, `shouldBindWorkersAi()` injects the owner's remote AI binding during development and preview, using the same Cloudflare credentials as the CLI. Production builds use the normal AI binding. The root `ai.binding` takes precedence over `inference.bindings.ai`, with `AI` as the default name; `__VOID_AI_BINDING` records a custom runtime name. Managed and unsaved projects keep the Void proxy path during development. Apps without inferred AI usage must perform no AI authentication or entitlement probe.
- **`mergeBindings` may remove a generated Cloudflare entry only under the name Void itself writes, gated on the matching `needs*` flag.** The Cloudflare plugin `defu`-merges the returned patch over `resolved` (arrays concatenate, `undefined` is skipped), so the only way to drop a key is to mutate `resolved` — and a mutation there is a change to the user's own config, which the CF-plugin-API rule above says takes precedence. The `send_email` serve-strip once deleted the whole field, so an app that drove Cloudflare's email API through its own `send_email: [{ name: "MAILER" }]` without ever importing `void/email` started `void dev` with `env.MAILER` undefined. It now filters out only the reserved `SEND_EMAIL` entry (`SEND_EMAIL_BINDING` from `cli/email-cloudflare.ts`, the name `void deploy --platform cloudflare` writes and the runtime reads), only when `bindings.needsEmail` — the same signal that arms the dev inbox — and drops the key only when nothing is left. `test/unit/plugin-email-config.test.ts` runs the real closure through a mocked `cloudflare()`.
- **Every `/__void/inbox` route requires the dev-trigger token; the worker's capture POST carries it via a var.** Vite's host validation (`server.allowedHosts`, runs before plugin middlewares) only rejects DNS-rebinding hostnames — any IP-literal `Host` passes — and its default CORS reflects every `localhost` origin, so an unauthenticated inbox middleware hands every peer under `--host` (and every other local dev server) the raw MIME with Bcc and attachments, a DELETE, and a capture endpoint to plant messages. `plugin-email-dev-inbox.ts` therefore checks the same per-project token the cron/queue dev routes and the pages dev-CSS loopback use (`getOrCreateDevTriggerToken`, `.void/dev-trigger-token`), accepted as the `x-void-dev-trigger` header (worker, curl) or `?token=` (browser — the plugin prints the tokenized URL on `listening` and renders its own links with it; responses already send `Referrer-Policy: no-referrer`). Missing or wrong → `401` with a one-line body naming both forms and no ack header. The compiled router embeds the token as a module-local const that `runtime/email.ts` cannot import, so `mergeBindings` injects `__VOID_EMAIL_DEV_INBOX_TOKEN` next to `__VOID_EMAIL_DEV_INBOX` under the one `shouldInjectEmailDevInboxVar` gate (dev only — never in a build's `wrangler.json`); `sendEmail` sends it and, on a non-2xx, echoes the first line of the dev server's body so a missing token is diagnosable. Nothing on the worker side ever skips the inbox for a real transport because the token is absent. Because that file is now the inbox's whole credential and loopback needs no `--host`, `getOrCreateDevTriggerToken` creates it `0600` (`openSync(.., 'wx', 0o600)`) and best-effort `chmod`s a pre-existing file whose group/other bits are set, never rotating it (skipped on Windows, which has no such bits) — a default-umask `0644` file would hand every other account on a shared host the raw MIME, the DELETE, and the capture endpoint. `.void/dev-triggers.ts` (`plugin-framework-triggers.ts`) embeds the same token for Class A frameworks and is written `0600` the same way. `/__void/restart`, `/__void/ping` and `/__void/restarting` remain open by design: they expose no data.
- **Every `/__void/inbox` route requires the dev-trigger token; the worker's capture POST carries it via a var.** Vite's host validation (`server.allowedHosts`, runs before plugin middlewares) only rejects DNS-rebinding hostnames — any IP-literal `Host` passes — and its default CORS reflects every `localhost` origin, so an unauthenticated inbox middleware hands every peer under `--host` (and every other local dev server) the raw MIME with Bcc and attachments, a DELETE, and a capture endpoint to plant messages. `plugin-email-dev-inbox.ts` therefore checks the same per-project token the cron/queue dev routes and the pages dev-CSS loopback use (`getOrCreateDevTriggerToken`, `.void/dev-trigger-token`), accepted as the `x-void-dev-trigger` header (worker, curl) or `?token=` (browser — the plugin prints the tokenized URL on `listening` and renders its own links with it; responses already send `Referrer-Policy: no-referrer`). Missing or wrong → `401` with a one-line body naming both forms and no ack header. The compiled router embeds the token as a module-local const that `runtime/email.ts` cannot import, so `mergeBindings` injects `__VOID_EMAIL_DEV_INBOX_TOKEN` next to `__VOID_EMAIL_DEV_INBOX` under the one `shouldInjectEmailDevInboxVar` gate (dev only — never in a build's `wrangler.json`); `sendEmail` sends it and, on a non-2xx, echoes the first line of the dev server's body so a missing token is diagnosable. Nothing on the worker side ever skips the inbox for a real transport because the token is absent. Because that file is now the inbox's whole credential and loopback needs no `--host`, `getOrCreateDevTriggerToken` creates it `0600` (`openSync(.., 'wx', 0o600)`) and best-effort `chmod`s a pre-existing file whose group/other bits are set, never rotating it (skipped on Windows, which has no such bits) — a default-umask `0644` file would hand every other account on a shared host the raw MIME, the DELETE, and the capture endpoint. `.void/dev-triggers.ts` (`plugin-framework-triggers.ts`) embeds the same token for Class A frameworks and is written `0600` the same way. `/__void/ping` stays open: it exposes no data. `/__void/restart` answers `403` unless the POST is same-origin (`Sec-Fetch-Site`, else `Origin`) and carries this server instance's random token in `x-void-restart` — browsers send no Fetch Metadata to plain-HTTP LAN origins (`--host`), so the token, embedded only in the `virtual:void/binding-hint` toast (which navigates with `&t=`; Vite's default CORS exposes it to localhost origins only, like the HMR token), is what stops a foreign page from opening `/__void/restarting` and riding its auto-POST. `/__void/restarting` refuses a navigation whose `Sec-Fetch-Site` is not `same-origin` or `none`, sends `Referrer-Policy: no-referrer`, and only navigates to a same-origin path from `?return=`, falling back to `/`.

- **Sandbox SDK 1.0**: Keep the packaged Dockerfile helper version and the managed controller’s verified helper-layer digest in sync with `@cloudflare/sandbox`. Native execution uses Durable Object scheduling with named images; managed controller protocol 2 preserves admission, metering, and cleanup, including retained protocol 1 deployments. Cancellation crosses Workers RPC as a stream, not an AbortSignal. Run the Sandbox RPC and lifecycle tests when changing this boundary.

Expand Down
165 changes: 154 additions & 11 deletions packages/void/src/plugin-restart.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { randomBytes, timingSafeEqual } from 'node:crypto';
import type { IncomingMessage } from 'node:http';
import type { TLSSocket } from 'node:tls';
import type { Plugin } from 'vite';
import { safeJsonStringify } from './pages/protocol.ts';

const BINDING_HINT_VIRTUAL = 'virtual:void/binding-hint';
const BINDING_HINT_RESOLVED = '\0' + BINDING_HINT_VIRTUAL;

const RESTARTING_HTML = `<!DOCTYPE html>
const RESTARTING_HTML_HEAD = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
Expand All @@ -21,25 +25,127 @@ const RESTARTING_HTML = `<!DOCTYPE html>
<body>
<div><span class="tag">[void]</span> Restarting dev server<span class="dots"><span>.</span><span>.</span><span>.</span></span></div>
<script>
fetch("/__void/restart",{method:"POST"}).catch(function(){});
var target=`;

const RESTARTING_HTML_MIDDLE = `;
var token=`;

const RESTARTING_HTML_TAIL = `;
fetch("/__void/restart",{method:"POST",headers:{"x-void-restart":token}}).catch(function(){});
setTimeout(function p(){
fetch("/__void/ping").then(function(r){
if(r.ok)location.href=new URLSearchParams(location.search).get("return")||"/";
if(r.ok)location.href=target;
else setTimeout(p,150);
}).catch(function(){setTimeout(p,150)});
},300);
</script>
</body>
</html>`;

const CLIENT_MODULE = `if (import.meta.hot) {
// eslint-disable-next-line no-control-regex -- rejecting control characters is the point
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;

/**
* Returns `value` when it is a path on the dev server's own origin, else `/`.
* The restarting page navigates to it, so a scheme (`javascript:`) or a
* protocol-relative form (`//host`, `/\host`) must never get through.
*/
export function sanitizeReturnPath(value: string | null | undefined): string {
if (!value || value[0] !== '/' || value[1] === '/' || value[1] === '\\') {
return '/';
}
if (CONTROL_CHARS.test(value)) {
return '/';
}
const base = 'http://void.invalid';
try {
if (new URL(value, base).origin !== base) {
return '/';
}
} catch {
return '/';
}
return value;
}

/** Header the restarting page sends the per-server restart token in. */
export const RESTART_TOKEN_HEADER = 'x-void-restart';

/**
* `token` is the `t` query value the toast navigated with. The page sends it
* back with its POST; `/__void/restart` checks it, not this page.
*/
export function renderRestartingPage(
returnPath: string | null | undefined,
token: string | null | undefined,
): string {
return (
RESTARTING_HTML_HEAD +
safeJsonStringify(sanitizeReturnPath(returnPath)) +
RESTARTING_HTML_MIDDLE +
safeJsonStringify(token ?? '') +
RESTARTING_HTML_TAIL
);
}

/** Whether `presented` is this server's restart token, compared in constant time. */
export function isRestartTokenValid(
presented: string | Array<string> | undefined,
expected: string,
): boolean {
if (typeof presented !== 'string' || expected.length === 0) {
return false;
}
const a = Buffer.from(presented);
const b = Buffer.from(expected);
return a.byteLength === b.byteLength && timingSafeEqual(a, b);
}

/**
* Whether a browser sent `req` from the dev server's own origin. Any page the
* developer visits can POST to localhost, so cross-site callers are refused.
* `Sec-Fetch-Site` is set by the browser and cannot be forged by a page;
* `Origin` is the fallback for browsers that do not send it. Clients that send
* neither (curl, scripts) are not browsers acting for another site.
*/
export function isSameOriginRequest(req: IncomingMessage): boolean {
const site = req.headers['sec-fetch-site'];
if (site !== undefined) {
return site === 'same-origin';
}
const origin = req.headers.origin;
if (origin === undefined) {
return true;
}
const host = req.headers.host;
if (!host) {
return false;
}
const scheme = (req.socket as TLSSocket | undefined)?.encrypted ? 'https' : 'http';
return origin === `${scheme}://${host}`;
}

/** Whether a browser navigated here from the dev origin itself or from the address bar. */
export function isSameOriginNavigation(req: IncomingMessage): boolean {
const site = req.headers['sec-fetch-site'];
return site === undefined || site === 'same-origin' || site === 'none';
}

/**
* The toast is the only in-app way to reach `/__void/restarting`, so it is
* the only place that knows the restart token. Vite's default `server.cors`
* lets only localhost origins read this module, the same exposure as the
* HMR token in `/@vite/client`.
*/
const clientModule = (token: string): string => `if (import.meta.hot) {
const token = ${JSON.stringify(token)};
import.meta.hot.on("void:new-binding", (d) => {
if (document.getElementById("void-binding-hint")) return;
const e = document.createElement("div");
e.id = "void-binding-hint";
e.style.cssText = "position:fixed;top:16px;right:16px;z-index:99999;padding:12px 16px;background:rgba(26,26,46,0.95);color:#e0def4;font:13px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;border-radius:8px;border:1px solid rgba(255,255,255,0.08);box-shadow:0 8px 32px rgba(0,0,0,0.4);backdrop-filter:blur(8px);cursor:pointer;max-width:300px;transform:translateX(calc(100% + 16px));transition:transform .3s ease";
e.innerHTML = "<strong style=\\"color:#c4a7e7\\">[void]</strong> " + d.message + " Click to restart.";
e.onclick = () => { location.href = "/__void/restarting?return=" + encodeURIComponent(location.pathname + location.search); };
e.onclick = () => { location.href = "/__void/restarting?return=" + encodeURIComponent(location.pathname + location.search) + "&t=" + encodeURIComponent(token); };
document.body.appendChild(e);
requestAnimationFrame(() => requestAnimationFrame(() => {
e.style.transform = "translateX(0)";
Expand All @@ -51,15 +157,23 @@ const CLIENT_MODULE = `if (import.meta.hot) {
* Vite plugin that provides controlled dev-server restart via the browser.
*
* Serves three endpoints:
* - `/__void/restarting` — standalone loading page (no Vite HMR client)
* - `/__void/restart` — POST triggers `server.restart()` after a short delay
* - `/__void/restarting` — standalone loading page (no Vite HMR client);
* same-origin navigation only
* - `/__void/restart` — same-origin POST carrying this server's restart
* token triggers `server.restart()` after a short delay
* - `/__void/ping` — health check; returns 503 on the old server instance
* (after restart is triggered) and 200 on the new one
*
* Also injects a virtual module (`virtual:void/binding-hint`) that listens for
* `void:new-binding` HMR events and shows a toast with a click-to-restart action.
*/
export function restartPlugin(): Plugin {
// Browsers omit Fetch Metadata for plain-HTTP non-localhost origins
// (`void dev --host` on a LAN IP), so the navigation check alone lets any
// page open `/__void/restarting`, whose POST then carries our own Origin.
// The token proves the navigation came from the toast. Each server instance
// (including the one `server.restart()` creates) gets a fresh one.
let restartToken = '';
return {
name: 'void:restart',
apply: 'serve',
Expand All @@ -75,12 +189,15 @@ export function restartPlugin(): Plugin {
// eslint-disable-next-line no-control-regex -- Vite virtual module null-byte prefix
filter: { id: /^\0virtual:void\/binding-hint$/ },
handler() {
return CLIENT_MODULE;
return clientModule(restartToken);
},
},

configureServer(server) {
let restartPending = false;
// This instance checks its own token; `load` serves the latest one.
const token = randomBytes(16).toString('base64url');
restartToken = token;

server.middlewares.use('/__void/ping', (_req, res) => {
res.writeHead(restartPending ? 503 : 200);
Expand All @@ -93,15 +210,41 @@ export function restartPlugin(): Plugin {
res.end();
return;
}
if (
!isSameOriginRequest(req) ||
!isRestartTokenValid(req.headers[RESTART_TOKEN_HEADER], token)
) {
res.writeHead(403);
res.end();
return;
}
restartPending = true;
res.writeHead(200);
res.end();
setTimeout(() => server.restart(), 50);
});

server.middlewares.use('/__void/restarting', (_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(RESTARTING_HTML);
server.middlewares.use('/__void/restarting', (req, res) => {
if (!isSameOriginNavigation(req)) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Restart the dev server from its own page.');
return;
}
let returnPath: string | null = null;
let presented: string | null = null;
try {
const params = new URL(req.url ?? '/', 'http://void.invalid').searchParams;
returnPath = params.get('return');
presented = params.get('t');
} catch {
// A request url that does not parse returns to `/` without restarting.
}
res.writeHead(200, {
'Content-Type': 'text/html',
'Cache-Control': 'no-store',
'Referrer-Policy': 'no-referrer',
});
res.end(renderRestartingPage(returnPath, presented));
});
},
};
Expand Down
Loading
Loading