Skip to content
Merged
2 changes: 2 additions & 0 deletions docs/guide/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ Choose a database during `void init`, or set `database` in `void.config.ts`:
| Managed by | Void | Bring your own database | Bring your own database |
| Connection | Automatic D1 binding | Hyperdrive | Hyperdrive |

A Void platform keeps Hyperdrive query caching off for your database connection, so reads reflect completed writes. An existing connection is updated on the project's next deploy.

## Schema Definition

Define your tables in `db/schema.ts` using column helpers from `void/schema-d1`, `void/schema-pg`, or `void/schema-mysql`.
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/database/postgresql.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ For a Void platform, deploy prompts for the connection string if needed. You can

Void provisions Hyperdrive without saving the connection string in project config. Both `postgres://` and `postgresql://` URLs are supported, including provider query strings such as `?sslmode=require`.

The Hyperdrive that Void creates connects with TLS in Cloudflare's default `require` mode. Hyperdrive does not use the `sslmode=verify-ca`, `sslmode=verify-full`, or `sslrootcert` settings from your connection string. To have Hyperdrive verify your database against a specific certificate authority on a direct Cloudflare deploy, [create the Hyperdrive in Cloudflare with that certificate](https://developers.cloudflare.com/hyperdrive/configuration/tls-ssl-certificates-for-hyperdrive/) and add its ID to [`cloudflare.hyperdrive`](../../integrations/cloudflare.md#databases-and-secrets).

## Schema Definition

Import schema helpers from `void/schema-pg`:
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/platform/installation/credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ Use the following permissions for the core platform. Cloudflare may label write
| Account | Hyperdrive | Write | Write |
| Account | Account Analytics | Read | Read |
| Account | AI Gateway | Edit | Edit when installing with browser login |
| Account | Containers | — | Edit |
| Account | Cloudchamber | — | Edit |
| Zone | Zone | Read | — |
| Zone | DNS | Edit | — |
| Zone | Workers Routes | Edit | — |
Expand Down
11 changes: 11 additions & 0 deletions packages/deploy-cloudflare/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ export function cloudflareCommandDiagnostic(result: CloudflareCommandResult): st
);
if (box) {
message = box[0].trim();
} else {
const lines = stdout.split('\n');
const abort = lines.find((line) =>
line.startsWith('■ Aborting the upload operation because of conflicts.'),
);
if (abort) {
const reason = 'You are about to upload a Worker that was last updated via the script API.';
message = [lines.includes(`▲ ${reason}`) ? reason : '', abort.slice('■ '.length)]
.filter(Boolean)
.join('\n');
}
}
}
// Upload progress and bindings are printed on stdout. Never echo that whole stream
Expand Down
1 change: 1 addition & 0 deletions packages/deploy-cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export {
type CloudflareVersionMetadata,
} from './version.ts';
export { createCloudflareMetadataRunner } from './metadata.ts';
export { cloudflareCommandDiagnostic } from './diagnostics.ts';
export { VOID_SECRET_DRAFT_SOURCE } from './secret-draft.ts';

export type {
Expand Down
4 changes: 3 additions & 1 deletion packages/void/src/cli/deploy-managed-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ export async function runStaticDeploy(
process.exit(1);
}

if (routing) {
// Full deploys can supply premerged rules; standalone static deploys still
// need file-based rules when no routing config was provided.
if (routing || (!headerRules && !redirectRules && !fallbackRules)) {
const merged = mergeAndLintRoutingRules(preset.outputDir, routing);
headerRules = merged.headerRules;
redirectRules = merged.redirectRules;
Expand Down
7 changes: 6 additions & 1 deletion packages/void/src/cli/platform-installer-errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
cloudflareCommandDiagnostic,
stabilizeCloudflareVersionTriggers,
type CloudflareCommandResult,
} from '@void/deploy-cloudflare';
Expand All @@ -8,8 +9,12 @@ export function assertCleanPlatformWrangler(
operation: string,
): void {
if (result.status !== 0 || result.signal || result.error) {
const diagnostic = cloudflareCommandDiagnostic({
...result,
stderr: result.stderr.trim() || result.errors?.map((error) => error.message).join('\n') || '',
});
throw new Error(
`platform: Wrangler failed to ${operation} (${result.stderr.trim() || `exit ${result.status}`}).`,
`platform: Wrangler failed to ${operation} (${diagnostic || `exit ${result.status}`}).`,
);
}
}
Expand Down
6 changes: 6 additions & 0 deletions packages/void/src/cli/platform-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ export function platformSetupPages(plan: PlatformInstallPlan): {
...(plan.deployment.capabilities.customDomains
? [{ key: 'ssl_and_certificates', type: 'edit' }]
: []),
...(plan.deployment.capabilities.sandbox
? [
{ key: 'containers', type: 'edit' },
{ key: 'cloudchamber', type: 'edit' },
]
: []),
// Email domain setup and destination consent use this token.
...(plan.deployment.capabilities.email
? [
Expand Down
52 changes: 52 additions & 0 deletions packages/void/test/unit/direct-r2-deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,58 @@ describe('direct-to-R2 deploy flow', () => {
expect(body.preflight).toBe('true');
});

it.each(['static', 'spa'] as const)(
'includes file-based routing without a routing config (%s)',
async (appType) => {
writeFileSync(join(assetsDir, '_headers'), '/about\n X-Test: file-rule\n');
writeFileSync(join(assetsDir, '_redirects'), '/old /about 302\n/rewrite /about 200\n');
const state = installPlatformMocks();

await runStaticDeploy(
workdir,
{ projectId: 'proj_t', slug: 'test' },
makeClient(),
{ buildCommand: null, outputDir: assetsDir, appType },
true,
);

const body = JSON.parse(state.finalizeCalls[0].init.body as string);
expect(body.manifest.headerRules).toEqual([
{ pattern: '/about', headers: [['X-Test', 'file-rule']] },
]);
expect(body.manifest.redirectRules).toEqual([
{ source: '/old', destination: '/about', status: 302 },
]);
expect(body.manifest.fallbackRules).toContainEqual({
source: '/rewrite',
destination: '/about',
status: 200,
});
},
);

it('preserves routing rules already merged by the full-to-static path', async () => {
writeFileSync(join(assetsDir, '_headers'), '/about\n X-Test: file-rule\n');
const headerRules = [
{ pattern: '/about', headers: [['X-Test', 'config-rule']] as Array<[string, string]> },
];
const state = installPlatformMocks();

await runStaticDeploy(
workdir,
{ projectId: 'proj_t', slug: 'test' },
makeClient(),
{ buildCommand: null, outputDir: assetsDir, appType: 'static' },
true,
undefined,
undefined,
headerRules,
);

const body = JSON.parse(state.finalizeCalls[0].init.body as string);
expect(body.manifest.headerRules).toEqual(headerRules);
});

// `routing.notFound` must reach the STATIC manifest, not only the
// worker-with-assets path. `detectPreset` classifies any Vite project with
// no backend files as `spa`, so a plain Vite MPA lands here and would
Expand Down
52 changes: 52 additions & 0 deletions packages/void/test/unit/platform-installer-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { expect, it } from 'vite-plus/test';
import { assertCleanPlatformWrangler } from '../../src/cli/platform-installer-errors.ts';

it('reports the cf upload conflict from stdout without binding values or completion output', () => {
const diagnostic =
'Aborting the upload operation because of conflicts. To override and upload anyway, remove the `--strict` flag';
const result = {
status: 1,
stderr: '',
stdout: [
'env.PRIVATE_VALUE: "do-not-echo"',
'├ Upload',
'▲ You are about to upload a Worker that was last updated via the script API.',
'│ Edits that have been made via the script API will be overridden by your local code and config.',
`■ ${diagnostic}`,
'│',
'◆ Version upload complete',
'',
].join('\n'),
};

expect(() => assertCleanPlatformWrangler(result, 'upload test-worker')).toThrow(
'platform: Wrangler failed to upload test-worker (' +
'You are about to upload a Worker that was last updated via the script API.\n' +
`${diagnostic}).`,
);
});

it('reports structured provider errors when stderr is empty using existing credential redaction', () => {
expect(() =>
assertCleanPlatformWrangler(
{
status: 1,
stdout: 'env.PRIVATE_VALUE: "do-not-echo"',
stderr: '',
errors: [{ code: 10000, message: 'Invalid Authorization: Bearer synthetic-token' }],
},
'upload test-worker',
),
).toThrow(
'platform: Wrangler failed to upload test-worker (Invalid Authorization: Bearer [redacted]).',
);
});

it('retains the exit status when stdout contains only binding values', () => {
expect(() =>
assertCleanPlatformWrangler(
{ status: 1, stdout: 'env.PRIVATE_VALUE: "do-not-echo"', stderr: '' },
'upload test-worker',
),
).toThrow('platform: Wrangler failed to upload test-worker (exit 1).');
});
29 changes: 29 additions & 0 deletions packages/void/test/unit/platform-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,35 @@ describe('platform credential setup links', () => {
expect(permissions(custom)).toContainEqual({ key: 'ssl_and_certificates', type: 'edit' });
expect(permissions(original)).not.toContainEqual({ key: 'ssl_and_certificates', type: 'edit' });
});
it.each([false, true])(
'requests Containers and Cloudchamber only for managed Sandbox, enabled: %s',
(sandbox) => {
const original = plan();
const selected = {
...original,
deployment: {
...original.deployment,
capabilities: { ...original.deployment.capabilities, sandbox },
},
};
const runtimePermissions = (input: PlatformInstallPlan) =>
JSON.parse(
new URL(platformSetupPages(input).runtime.url).searchParams.get('permissionGroupKeys')!,
);
expect(runtimePermissions(selected)).toEqual([
...runtimePermissions(original),
...(sandbox
? [
{ key: 'containers', type: 'edit' },
{ key: 'cloudchamber', type: 'edit' },
]
: []),
]);
expect(platformManagementSetupPage(selected).url).toBe(
platformManagementSetupPage(original).url,
);
},
);
it('requests the email zone and sending permissions only when email is enabled', () => {
const original = plan('example.com');
const withEmail = {
Expand Down
4 changes: 4 additions & 0 deletions platform/packages/api/src/admin/react/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@ body {
margin: 0;
}
.void-admin-team-table-scroll {
--container-padding-inline-start: 0px;
--container-padding-inline-end: 0px;
--container-padding-block-start: 0px;
--container-padding-block-end: 0px;
min-width: 0;
max-width: 100%;
overflow-x: auto;
Expand Down
2 changes: 1 addition & 1 deletion platform/packages/api/src/email/connection-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1468,7 +1468,7 @@ export async function requestEmailDomainAdd(
: preflight[0].length > 0;
if (mxConflict) {
throw new EmailOperationError(
`${domain} already has MX records; use an unused mail subdomain`,
`${domain} already has MX records; use an unused domain apex or a platform shared email address`,
409,
);
}
Expand Down
2 changes: 1 addition & 1 deletion platform/packages/api/src/operator/email-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,7 @@ export async function addOperatorEmailDomain(
effect: existing
? `The existing assignment for ${domain} belongs to ${project.slug}; use sync to reconcile it.`
: mx === 'subdomain'
? `${domain} already receives mail; setup will refuse it — name a subdomain such as mail.${domain} instead.`
? `${domain} already receives mail; setup will refuse it — use an unused domain apex or a platform shared email address instead.`
: credentialKind === 'platform'
? `Registers ${domain} for ${project.slug} on the platform's own Cloudflare credential; the zone must be in the platform's account.`
: `Registers ${domain} for ${project.slug} with the supplied API token.`,
Expand Down
48 changes: 37 additions & 11 deletions platform/packages/api/src/provisioning/cloudflare-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,6 @@ type HyperdriveOrigin = Readonly<{
type HyperdriveConfigPayload = Readonly<{
name?: string;
origin: HyperdriveOrigin;
mtls?: Readonly<{ sslmode: 'require' | 'verify-ca' | 'verify-full' }>;
}>;

function decodeUrlPart(value: string): string {
Expand Down Expand Up @@ -637,15 +636,14 @@ export function parseHyperdriveConnectionString(connectionString: string): Hyper
}

const sslmode = url.searchParams.get('sslmode');
let mtls: HyperdriveConfigPayload['mtls'];
if (sslmode) {
if (sslmode !== 'require' && sslmode !== 'verify-ca' && sslmode !== 'verify-full') {
throw new InvalidHyperdriveConnectionStringError(
'Unsupported sslmode. Cloudflare Hyperdrive supports "require", "verify-ca", or "verify-full".',
);
}
mtls = { sslmode };
if (sslmode && sslmode !== 'require' && sslmode !== 'verify-ca' && sslmode !== 'verify-full') {
throw new InvalidHyperdriveConnectionStringError(
'Unsupported sslmode. Cloudflare Hyperdrive supports "require", "verify-ca", or "verify-full".',
);
}
// No `mtls` is sent. A new Hyperdrive gets Cloudflare's default `require` mode, and a PUT
// keeps the TLS settings already on the Hyperdrive. The verify modes need a CA certificate
// uploaded to Cloudflare, which a URL cannot carry; `sslrootcert` names a file on the client.

return {
origin: {
Expand All @@ -656,7 +654,6 @@ export function parseHyperdriveConnectionString(connectionString: string): Hyper
scheme,
user: decodeUrlPart(url.username),
},
...(mtls === undefined ? {} : { mtls }),
};
}

Expand All @@ -666,7 +663,11 @@ export async function createHyperdrive(
name: string,
connectionString: string,
): Promise<HyperdriveInfo> {
const payload = { name, ...parseHyperdriveConnectionString(connectionString) };
const payload = {
name,
caching: { disabled: true },
...parseHyperdriveConnectionString(connectionString),
};
return cfFetch<HyperdriveInfo>(`${CF_API_BASE}/${accountId}/hyperdrive/configs`, apiToken, {
method: 'POST',
body: JSON.stringify(payload),
Expand All @@ -682,6 +683,7 @@ export async function updateHyperdrive(
): Promise<HyperdriveInfo> {
const payload = {
...(name === undefined ? {} : { name }),
caching: { disabled: true },
...parseHyperdriveConnectionString(connectionString),
};
return cfFetch<HyperdriveInfo>(
Expand All @@ -694,6 +696,30 @@ export async function updateHyperdrive(
);
}

/**
* Hyperdrive never invalidates cached reads when the app writes, so project
* databases run uncached. A config may predate this setting, so deploys
* converge it. PATCH changes caching only and needs no origin password.
*/
export async function ensureHyperdriveCachingDisabled(
accountId: string,
apiToken: string,
hyperdriveId: string,
): Promise<void> {
const url = `${CF_API_BASE}/${accountId}/hyperdrive/configs/${hyperdriveId}`;
const config = await cfFetch<Readonly<{ caching?: Readonly<{ disabled?: boolean }> }>>(
url,
apiToken,
);
if (config.caching?.disabled === true) {
return;
}
await cfFetch(url, apiToken, {
method: 'PATCH',
body: JSON.stringify({ caching: { disabled: true } }),
});
}

export async function deleteHyperdrive(
accountId: string,
apiToken: string,
Expand Down
13 changes: 8 additions & 5 deletions platform/packages/api/src/provisioning/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
deleteQueue,
deleteR2Bucket,
enableD1ReadReplication,
ensureHyperdriveCachingDisabled,
getD1DatabaseByName,
getKVNamespaceByTitle,
getQueueByName,
Expand Down Expand Up @@ -149,11 +150,13 @@ export async function provisionBindings(
);
}
tasks.push(
Promise.resolve({
type: 'hyperdrive' as const,
name: resource.bindingName,
id: found.cf_id,
}),
ensureHyperdriveCachingDisabled(accountId, apiToken, found.cf_id).then(
(): WorkerBinding => ({
type: 'hyperdrive',
name: resource.bindingName,
id: found.cf_id,
}),
),
);
break;
}
Expand Down
Loading
Loading