From 0a4b31ffeb41ad1dfb3141384e22787763fcae3d Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Tue, 9 Jul 2024 19:46:37 +0200 Subject: [PATCH 01/10] feat: astro:env validateSecrets (#11337) Co-authored-by: Sarah Rainsberger --- .changeset/slow-roses-call.md | 23 ++++ packages/astro/src/@types/astro.ts | 31 ++++++ packages/astro/src/core/config/schema.ts | 4 + packages/astro/src/env/vite-plugin-env.ts | 23 ++-- packages/astro/test/env-secret.test.js | 125 ++++++++++++---------- 5 files changed, 143 insertions(+), 63 deletions(-) create mode 100644 .changeset/slow-roses-call.md diff --git a/.changeset/slow-roses-call.md b/.changeset/slow-roses-call.md new file mode 100644 index 000000000000..9217f96fe965 --- /dev/null +++ b/.changeset/slow-roses-call.md @@ -0,0 +1,23 @@ +--- +'astro': patch +--- + +Adds a new property `experimental.env.validateSecrets` to allow validating private variables on the server. + +By default, this is set to `false` and only public variables are checked on start. If enabled, secrets will also be checked on start (dev/build modes). This is useful for example in some CIs to make sure all your secrets are correctly set before deploying. + +```js +// astro.config.mjs +import { defineConfig, envField } from "astro/config" + +export default defineConfig({ + experimental: { + env: { + schema: { + // ... + }, + validateSecrets: true + } + } +}) +``` diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 3668aa9de33f..215f2160f527 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -2166,6 +2166,37 @@ export interface AstroUserConfig { * ``` */ schema?: EnvSchema; + + /** + * @docs + * @name experimental.env.validateSecrets + * @kind h4 + * @type {boolean} + * @default `false` + * @version 4.11.6 + * @description + * + * Whether or not to validate secrets on the server when starting the dev server or running a build. + * + * By default, only public variables are validated on the server when starting the dev server or a build, and private variables are validated at runtime only. If enabled, private variables will also be checked on start. This is useful in some continuous integration (CI) pipelines to make sure all your secrets are correctly set before deploying. + * + * ```js + * // astro.config.mjs + * import { defineConfig, envField } from "astro/config" + * + * export default defineConfig({ + * experimental: { + * env: { + * schema: { + * // ... + * }, + * validateSecrets: true + * } + * } + * }) + * ``` + */ + validateSecrets?: boolean; }; }; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 15025a765fd8..50247133c323 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -89,6 +89,9 @@ export const ASTRO_CONFIG_DEFAULTS = { clientPrerender: false, globalRoutePriority: false, rewriting: false, + env: { + validateSecrets: false + } }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -526,6 +529,7 @@ export const AstroConfigSchema = z.object({ env: z .object({ schema: EnvSchema.optional(), + validateSecrets: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.env.validateSecrets) }) .strict() .optional(), diff --git a/packages/astro/src/env/vite-plugin-env.ts b/packages/astro/src/env/vite-plugin-env.ts index 3b08d8c2ed1d..1bcb021e0f51 100644 --- a/packages/astro/src/env/vite-plugin-env.ts +++ b/packages/astro/src/env/vite-plugin-env.ts @@ -52,7 +52,11 @@ export function astroEnv({ } } - const validatedVariables = validatePublicVariables({ schema, loadedEnv }); + const validatedVariables = validatePublicVariables({ + schema, + loadedEnv, + validateSecrets: settings.config.experimental.env?.validateSecrets ?? false, + }); templates = { ...getTemplates(schema, fs, validatedVariables), @@ -94,23 +98,28 @@ function resolveVirtualModuleId(id: T): `\0${T}` { function validatePublicVariables({ schema, loadedEnv, + validateSecrets, }: { schema: EnvSchema; loadedEnv: Record; + validateSecrets: boolean; }) { const valid: Array<{ key: string; value: any; type: string; context: 'server' | 'client' }> = []; const invalid: Array<{ key: string; type: string }> = []; for (const [key, options] of Object.entries(schema)) { - if (options.access !== 'public') { + const variable = loadedEnv[key] === '' ? undefined : loadedEnv[key]; + + if (options.access === 'secret' && !validateSecrets) { continue; } - const variable = loadedEnv[key]; - const result = validateEnvVariable(variable === '' ? undefined : variable, options); - if (result.ok) { - valid.push({ key, value: result.value, type: result.type, context: options.context }); - } else { + + const result = validateEnvVariable(variable, options); + if (!result.ok) { invalid.push({ key, type: result.type }); + // We don't do anything with validated secrets so we don't store them + } else if (options.access === 'public') { + valid.push({ key, value: result.value, type: result.type, context: options.context }); } } diff --git a/packages/astro/test/env-secret.test.js b/packages/astro/test/env-secret.test.js index 562f011410aa..59011270d233 100644 --- a/packages/astro/test/env-secret.test.js +++ b/packages/astro/test/env-secret.test.js @@ -1,77 +1,90 @@ import assert from 'node:assert/strict'; -import { writeFileSync } from 'node:fs'; -import { after, describe, it } from 'node:test'; +import { afterEach, describe, it } from 'node:test'; import * as cheerio from 'cheerio'; import testAdapter from './test-adapter.js'; import { loadFixture } from './test-utils.js'; -describe('astro:env public variables', () => { +describe('astro:env secret variables', () => { /** @type {Awaited>} */ let fixture; - /** @type {Awaited>} */ - let app; - /** @type {Awaited>} */ + /** @type {Awaited> | undefined} */ let devServer = undefined; - describe('Server variables', () => { - after(async () => { - await devServer?.stop(); + afterEach(async () => { + await devServer?.stop(); + if (process.env.KNOWN_SECRET) { + delete process.env.KNOWN_SECRET + } + }); + + it('works in dev', async () => { + process.env.KNOWN_SECRET = '5' + fixture = await loadFixture({ + root: './fixtures/astro-env-server-secret/', }); + devServer = await fixture.startDevServer(); + const response = await fixture.fetch('/'); + assert.equal(response.status, 200); + }); - it('works in dev', async () => { - writeFileSync( - new URL('./fixtures/astro-env-server-secret/.env', import.meta.url), - 'KNOWN_SECRET=5', - 'utf-8' - ); - fixture = await loadFixture({ - root: './fixtures/astro-env-server-secret/', - }); - devServer = await fixture.startDevServer(); - const response = await fixture.fetch('/'); - assert.equal(response.status, 200); + it('builds without throwing', async () => { + fixture = await loadFixture({ + root: './fixtures/astro-env-server-secret/', + output: 'server', + adapter: testAdapter({ + env: { + KNOWN_SECRET: '123456', + UNKNOWN_SECRET: 'abc', + }, + }), }); + await fixture.build(); + assert.equal(true, true); + }); - it('builds without throwing', async () => { - fixture = await loadFixture({ - root: './fixtures/astro-env-server-secret/', - output: 'server', - adapter: testAdapter({ - env: { - KNOWN_SECRET: '123456', - UNKNOWN_SECRET: 'abc', - }, - }), - }); - await fixture.build(); - app = await fixture.loadTestAdapterApp(); - assert.equal(true, true); + it('adapter can set how env is retrieved', async () => { + fixture = await loadFixture({ + root: './fixtures/astro-env-server-secret/', + output: 'server', + adapter: testAdapter({ + env: { + KNOWN_SECRET: '123456', + UNKNOWN_SECRET: 'abc', + }, + }), }); + await fixture.build(); + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + assert.equal(response.status, 200); - it('adapter can set how env is retrieved', async () => { - fixture = await loadFixture({ - root: './fixtures/astro-env-server-secret/', - output: 'server', - adapter: testAdapter({ - env: { - KNOWN_SECRET: '123456', - UNKNOWN_SECRET: 'abc', - }, - }), - }); - await fixture.build(); - app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/'); - const response = await app.render(request); - assert.equal(response.status, 200); + const html = await response.text(); + const $ = cheerio.load(html); - const html = await response.text(); - const $ = cheerio.load(html); + const data = JSON.parse($('#data').text()); - const data = JSON.parse($('#data').text()); + assert.equal(data.KNOWN_SECRET, 123456); + assert.equal(data.UNKNOWN_SECRET, 'abc'); + }); - assert.equal(data.KNOWN_SECRET, 123456); - assert.equal(data.UNKNOWN_SECRET, 'abc'); + it('fails if validateSecrets is enabled and secret is not set', async () => { + fixture = await loadFixture({ + root: './fixtures/astro-env-server-secret/', + experimental: { + env: { + validateSecrets: true, + }, + }, }); + + try { + await fixture.build(); + assert.fail() + } catch (error) { + assert.equal(error instanceof Error, true); + assert.equal(error.title, 'Invalid Environment Variables'); + assert.equal(error.message.includes('Variable KNOWN_SECRET is not of type: number.'), true); + } }); }); From ea8582f4fcb8f66b34dfaa41d562903ee22c0318 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Tue, 9 Jul 2024 17:47:29 +0000 Subject: [PATCH 02/10] [ci] format --- packages/astro/src/@types/astro.ts | 2 +- packages/astro/src/core/config/schema.ts | 9 ++++++--- packages/astro/test/env-secret.test.js | 6 +++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 215f2160f527..da0dcc9b6e9c 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -2179,7 +2179,7 @@ export interface AstroUserConfig { * Whether or not to validate secrets on the server when starting the dev server or running a build. * * By default, only public variables are validated on the server when starting the dev server or a build, and private variables are validated at runtime only. If enabled, private variables will also be checked on start. This is useful in some continuous integration (CI) pipelines to make sure all your secrets are correctly set before deploying. - * + * * ```js * // astro.config.mjs * import { defineConfig, envField } from "astro/config" diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 50247133c323..bb3130137eeb 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -90,8 +90,8 @@ export const ASTRO_CONFIG_DEFAULTS = { globalRoutePriority: false, rewriting: false, env: { - validateSecrets: false - } + validateSecrets: false, + }, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -529,7 +529,10 @@ export const AstroConfigSchema = z.object({ env: z .object({ schema: EnvSchema.optional(), - validateSecrets: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.env.validateSecrets) + validateSecrets: z + .boolean() + .optional() + .default(ASTRO_CONFIG_DEFAULTS.experimental.env.validateSecrets), }) .strict() .optional(), diff --git a/packages/astro/test/env-secret.test.js b/packages/astro/test/env-secret.test.js index 59011270d233..4505254a6b62 100644 --- a/packages/astro/test/env-secret.test.js +++ b/packages/astro/test/env-secret.test.js @@ -13,12 +13,12 @@ describe('astro:env secret variables', () => { afterEach(async () => { await devServer?.stop(); if (process.env.KNOWN_SECRET) { - delete process.env.KNOWN_SECRET + delete process.env.KNOWN_SECRET; } }); it('works in dev', async () => { - process.env.KNOWN_SECRET = '5' + process.env.KNOWN_SECRET = '5'; fixture = await loadFixture({ root: './fixtures/astro-env-server-secret/', }); @@ -80,7 +80,7 @@ describe('astro:env secret variables', () => { try { await fixture.build(); - assert.fail() + assert.fail(); } catch (error) { assert.equal(error instanceof Error, true); assert.equal(error.title, 'Invalid Environment Variables'); From 08baf56f328ce4b6814a7f90089c0b3398d8bbfe Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Tue, 9 Jul 2024 15:54:49 -0400 Subject: [PATCH 03/10] Actions: expand `isInputError` to accept `unknown` (#11439) * feat: allow type `unknown` on `isInputError` * chore: move ErrorInferenceObject to internal utils * chore: changeset * deps: expect-type * feat: first types test * chore: add types test to general test command * refactor: use describe and it for organization --- .changeset/nasty-poems-juggle.md | 18 +++++++++++ package.json | 1 + packages/astro/package.json | 4 ++- packages/astro/src/actions/runtime/utils.ts | 11 +++++++ .../src/actions/runtime/virtual/server.ts | 10 ++---- .../src/actions/runtime/virtual/shared.ts | 8 +++-- packages/astro/test/types/is-input-error.ts | 31 +++++++++++++++++++ packages/astro/tsconfig.tests.json | 9 ++++++ pnpm-lock.yaml | 10 ++++++ 9 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 .changeset/nasty-poems-juggle.md create mode 100644 packages/astro/test/types/is-input-error.ts create mode 100644 packages/astro/tsconfig.tests.json diff --git a/.changeset/nasty-poems-juggle.md b/.changeset/nasty-poems-juggle.md new file mode 100644 index 000000000000..74e1b176d036 --- /dev/null +++ b/.changeset/nasty-poems-juggle.md @@ -0,0 +1,18 @@ +--- +'astro': patch +--- + +Expands the `isInputError()` utility from `astro:actions` to accept errors of any type. This should now allow type narrowing from a try / catch block. + +```ts +// example.ts +import { actions, isInputError } from 'astro:actions'; + +try { + await actions.like(new FormData()); +} catch (error) { + if (isInputError(error)) { + console.log(error.fields); + } +} +``` diff --git a/package.json b/package.json index 4d477912b2ba..e99e740da097 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:citgm": "pnpm -r --filter=astro test", "test:match": "cd packages/astro && pnpm run test:match", "test:unit": "cd packages/astro && pnpm run test:unit", + "test:types": "cd packages/astro && pnpm run test:types", "test:unit:match": "cd packages/astro && pnpm run test:unit:match", "test:smoke": "pnpm test:smoke:example && pnpm test:smoke:docs", "test:smoke:example": "turbo run build --concurrency=100% --filter=\"@example/*\"", diff --git a/packages/astro/package.json b/packages/astro/package.json index 164d155a70d5..c66cb301e39e 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -114,12 +114,13 @@ "build:ci": "pnpm run prebuild && astro-scripts build \"src/**/*.{ts,js}\" && pnpm run postbuild", "dev": "astro-scripts dev --copy-wasm --prebuild \"src/runtime/server/astro-island.ts\" --prebuild \"src/runtime/client/{idle,load,media,only,visible}.ts\" \"src/**/*.{ts,js}\"", "postbuild": "astro-scripts copy \"src/**/*.astro\" && astro-scripts copy \"src/**/*.wasm\"", - "test": "pnpm run test:node", + "test": "pnpm run test:node && pnpm run test:types", "test:match": "pnpm run test:node --match", "test:e2e": "pnpm test:e2e:chrome && pnpm test:e2e:firefox", "test:e2e:match": "playwright test -g", "test:e2e:chrome": "playwright test", "test:e2e:firefox": "playwright test --config playwright.firefox.config.js", + "test:types": "tsc --project tsconfig.tests.json", "test:node": "astro-scripts test \"test/**/*.test.js\"" }, "dependencies": { @@ -215,6 +216,7 @@ "astro-scripts": "workspace:*", "cheerio": "1.0.0-rc.12", "eol": "^0.9.1", + "expect-type": "^0.19.0", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.1.2", "memfs": "^4.9.3", diff --git a/packages/astro/src/actions/runtime/utils.ts b/packages/astro/src/actions/runtime/utils.ts index eac9b92cf99e..02961144b4e2 100644 --- a/packages/astro/src/actions/runtime/utils.ts +++ b/packages/astro/src/actions/runtime/utils.ts @@ -31,3 +31,14 @@ export async function getAction( } return actionLookup; } + +/** + * Used to preserve the input schema type in the error object. + * This allows for type inference on the `fields` property + * when type narrowed to an `ActionInputError`. + * + * Example: Action has an input schema of `{ name: z.string() }`. + * When calling the action and checking `isInputError(result.error)`, + * `result.error.fields` will be typed with the `name` field. + */ +export type ErrorInferenceObject = Record; diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts index ce4d5f69662b..4d3745a6874d 100644 --- a/packages/astro/src/actions/runtime/virtual/server.ts +++ b/packages/astro/src/actions/runtime/virtual/server.ts @@ -1,13 +1,7 @@ import { z } from 'zod'; import { type ActionAPIContext, getApiContext as _getApiContext } from '../store.js'; -import { type MaybePromise } from '../utils.js'; -import { - ActionError, - ActionInputError, - type ErrorInferenceObject, - type SafeResult, - callSafely, -} from './shared.js'; +import type { ErrorInferenceObject, MaybePromise } from '../utils.js'; +import { ActionError, ActionInputError, type SafeResult, callSafely } from './shared.js'; export * from './shared.js'; diff --git a/packages/astro/src/actions/runtime/virtual/shared.ts b/packages/astro/src/actions/runtime/virtual/shared.ts index 420682aad4fc..94a22f7ca388 100644 --- a/packages/astro/src/actions/runtime/virtual/shared.ts +++ b/packages/astro/src/actions/runtime/virtual/shared.ts @@ -1,5 +1,5 @@ import type { z } from 'zod'; -import type { MaybePromise } from '../utils.js'; +import type { ErrorInferenceObject, MaybePromise } from '../utils.js'; type ActionErrorCode = | 'BAD_REQUEST' @@ -40,8 +40,6 @@ const statusToCodeMap: Record = Object.entries(codeToSt {} ); -export type ErrorInferenceObject = Record; - export class ActionError extends Error { type = 'AstroActionError'; code: ActionErrorCode = 'INTERNAL_SERVER_ERROR'; @@ -85,6 +83,10 @@ export class ActionError export function isInputError( error?: ActionError +): error is ActionInputError; +export function isInputError(error?: unknown): error is ActionInputError; +export function isInputError( + error?: unknown | ActionError ): error is ActionInputError { return error instanceof ActionInputError; } diff --git a/packages/astro/test/types/is-input-error.ts b/packages/astro/test/types/is-input-error.ts new file mode 100644 index 000000000000..ba0f7c0bc42f --- /dev/null +++ b/packages/astro/test/types/is-input-error.ts @@ -0,0 +1,31 @@ +import { expectTypeOf } from 'expect-type'; +import { isInputError, defineAction } from '../../dist/actions/runtime/virtual/server.js'; +import { z } from '../../zod.mjs'; +import { describe, it } from 'node:test'; + +const exampleAction = defineAction({ + input: z.object({ + name: z.string(), + }), + handler: () => {}, +}); + +const result = await exampleAction.safe({ name: 'Alice' }); + +describe('isInputError', () => { + it('isInputError narrows unknown error types', async () => { + try { + await exampleAction({ name: 'Alice' }); + } catch (e) { + if (isInputError(e)) { + expectTypeOf(e.fields).toEqualTypeOf>(); + } + } + }); + + it('`isInputError` preserves `fields` object type for ActionError objects', async () => { + if (isInputError(result.error)) { + expectTypeOf(result.error.fields).toEqualTypeOf<{ name?: string[] }>(); + } + }); +}); diff --git a/packages/astro/tsconfig.tests.json b/packages/astro/tsconfig.tests.json new file mode 100644 index 000000000000..1178731fa2c9 --- /dev/null +++ b/packages/astro/tsconfig.tests.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["test/types"], + "compilerOptions": { + "allowJs": true, + "emitDeclarationOnly": false, + "noEmit": true, + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5e9bf3dda3b..934cd36fcb26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -794,6 +794,9 @@ importers: eol: specifier: ^0.9.1 version: 0.9.1 + expect-type: + specifier: ^0.19.0 + version: 0.19.0 mdast-util-mdx: specifier: ^3.0.0 version: 3.0.0 @@ -8654,6 +8657,10 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + expect-type@0.19.0: + resolution: {integrity: sha512-piv9wz3IrAG4Wnk2A+n2VRCHieAyOSxrRLU872Xo6nyn39kYXKDALk4OcqnvLRnFvkz659CnWC8MWZLuuQnoqg==} + engines: {node: '>=12.0.0'} + express@4.19.2: resolution: {integrity: sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==} engines: {node: '>= 0.10.0'} @@ -9308,6 +9315,7 @@ packages: libsql@0.3.12: resolution: {integrity: sha512-to30hj8O3DjS97wpbKN6ERZ8k66MN1IaOfFLR6oHqd25GMiPJ/ZX0VaZ7w+TsPmxcFS3p71qArj/hiedCyvXCg==} + cpu: [x64, arm64, wasm32] os: [darwin, linux, win32] lilconfig@2.1.0: @@ -14704,6 +14712,8 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + expect-type@0.19.0: {} + express@4.19.2: dependencies: accepts: 1.3.8 From aa73316f20331690d55e3f83163f682ac1f68281 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Tue, 9 Jul 2024 19:55:39 +0000 Subject: [PATCH 04/10] [ci] format --- packages/astro/test/types/is-input-error.ts | 4 ++-- packages/astro/tsconfig.tests.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/astro/test/types/is-input-error.ts b/packages/astro/test/types/is-input-error.ts index ba0f7c0bc42f..2ab65e3d9e9f 100644 --- a/packages/astro/test/types/is-input-error.ts +++ b/packages/astro/test/types/is-input-error.ts @@ -1,7 +1,7 @@ +import { describe, it } from 'node:test'; import { expectTypeOf } from 'expect-type'; -import { isInputError, defineAction } from '../../dist/actions/runtime/virtual/server.js'; +import { defineAction, isInputError } from '../../dist/actions/runtime/virtual/server.js'; import { z } from '../../zod.mjs'; -import { describe, it } from 'node:test'; const exampleAction = defineAction({ input: z.object({ diff --git a/packages/astro/tsconfig.tests.json b/packages/astro/tsconfig.tests.json index 1178731fa2c9..1984bc4fe66a 100644 --- a/packages/astro/tsconfig.tests.json +++ b/packages/astro/tsconfig.tests.json @@ -4,6 +4,6 @@ "compilerOptions": { "allowJs": true, "emitDeclarationOnly": false, - "noEmit": true, + "noEmit": true } } From 7dca68ff2e0f089a3fd090650ee05b1942792fed Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Tue, 9 Jul 2024 16:35:28 -0400 Subject: [PATCH 05/10] Fix: Actions `accept` type completions (#11436) * fix: `accept` type completions * chore: changeset * Edit: fix -> fixes astro:actions Co-authored-by: Florian Lefebvre * feat(test): accept types --------- Co-authored-by: bholmesdev Co-authored-by: Florian Lefebvre --- .changeset/swift-cows-walk.md | 5 +++ .../src/actions/runtime/virtual/server.ts | 6 +-- .../astro/test/types/define-action-accept.ts | 45 +++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 .changeset/swift-cows-walk.md create mode 100644 packages/astro/test/types/define-action-accept.ts diff --git a/.changeset/swift-cows-walk.md b/.changeset/swift-cows-walk.md new file mode 100644 index 000000000000..212d0417e699 --- /dev/null +++ b/.changeset/swift-cows-walk.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Fixes `astro:actions` autocompletion for the `defineAction` `accept` property diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts index 4d3745a6874d..500336f754d3 100644 --- a/packages/astro/src/actions/runtime/virtual/server.ts +++ b/packages/astro/src/actions/runtime/virtual/server.ts @@ -11,7 +11,7 @@ export { z } from 'zod'; export const getApiContext = _getApiContext; export type Accept = 'form' | 'json'; -export type InputSchema = T extends 'form' +export type InputSchema = T extends 'form' ? z.AnyZodObject | z.ZodType : z.ZodType; @@ -21,7 +21,7 @@ type Handler = TInputSchema extends z.ZodType export type ActionClient< TOutput, - TAccept extends Accept, + TAccept extends Accept | undefined, TInputSchema extends InputSchema | undefined, > = TInputSchema extends z.ZodType ? (( @@ -44,7 +44,7 @@ export type ActionClient< export function defineAction< TOutput, - TAccept extends Accept = 'json', + TAccept extends Accept | undefined = undefined, TInputSchema extends InputSchema | undefined = TAccept extends 'form' ? // If `input` is omitted, default to `FormData` for forms and `any` for JSON. z.ZodType diff --git a/packages/astro/test/types/define-action-accept.ts b/packages/astro/test/types/define-action-accept.ts new file mode 100644 index 000000000000..c9a9ca315a34 --- /dev/null +++ b/packages/astro/test/types/define-action-accept.ts @@ -0,0 +1,45 @@ +import { describe, it } from 'node:test'; +import { expectTypeOf } from 'expect-type'; +import { defineAction } from '../../dist/actions/runtime/virtual/server.js'; +import { z } from '../../zod.mjs'; + +describe('defineAction accept', () => { + it('accepts type `any` when input is omitted with accept json', async () => { + const action = defineAction({ + handler: () => {}, + }); + expectTypeOf(action).parameter(0).toBeAny(); + expectTypeOf(action).parameter(0).not.toEqualTypeOf(); + + const jsonAction = defineAction({ + accept: 'json', + handler: () => {}, + }); + expectTypeOf(jsonAction).parameter(0).toBeAny(); + expectTypeOf(jsonAction).parameter(0).not.toEqualTypeOf(); + }); + it('accepts type `FormData` when input is omitted with accept form', async () => { + const action = defineAction({ + accept: 'form', + handler: () => {}, + }); + expectTypeOf(action).parameter(0).toEqualTypeOf(); + }); + + it('accept type safe values for input with accept json', async () => { + const action = defineAction({ + input: z.object({ name: z.string() }), + handler: () => {}, + }); + expectTypeOf(action).parameter(0).toEqualTypeOf<{ name: string }>(); + }); + + it('accepts type `FormData` for all inputs with accept form', async () => { + const action = defineAction({ + accept: 'form', + input: z.object({ name: z.string() }), + handler: () => {}, + }); + expectTypeOf(action).parameter(0).toEqualTypeOf(); + }); +}); From ea4bc04e9489c456e2b4b5dbd67d5e4cf3f89f97 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Wed, 10 Jul 2024 07:05:13 -0400 Subject: [PATCH 06/10] feat: `ActionReturnType` (#11443) * feat: ActionReturnType util * feat(test): ActionReturnType * chore: changeset --- .changeset/small-vans-own.md | 29 +++++++++++++++++++ .../src/actions/runtime/virtual/server.ts | 2 ++ .../astro/test/types/action-return-type.ts | 18 ++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 .changeset/small-vans-own.md create mode 100644 packages/astro/test/types/action-return-type.ts diff --git a/.changeset/small-vans-own.md b/.changeset/small-vans-own.md new file mode 100644 index 000000000000..06352e256af0 --- /dev/null +++ b/.changeset/small-vans-own.md @@ -0,0 +1,29 @@ +--- +'astro': patch +--- + +Expose new `ActionReturnType` utility from `astro:actions`. This infers the return type of an action by passing `typeof actions.name` as a type argument. This example defines a `like` action that returns `likes` as an object: + +```ts +// actions/index.ts +import { defineAction } from 'astro:actions'; + +export const server = { + like: defineAction({ + handler: () => { + /* ... */ + return { likes: 42 } + } + }) +} +``` + +In your client code, you can infer this handler return value with `ActionReturnType`: + +```ts +// client.ts +import { actions, ActionReturnType } from 'astro:actions'; + +type LikesResult = ActionReturnType; +// -> { likes: number } +``` diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts index 500336f754d3..c034be8bc8d8 100644 --- a/packages/astro/src/actions/runtime/virtual/server.ts +++ b/packages/astro/src/actions/runtime/virtual/server.ts @@ -19,6 +19,8 @@ type Handler = TInputSchema extends z.ZodType ? (input: z.infer, context: ActionAPIContext) => MaybePromise : (input: any, context: ActionAPIContext) => MaybePromise; +export type ActionReturnType> = Awaited>; + export type ActionClient< TOutput, TAccept extends Accept | undefined, diff --git a/packages/astro/test/types/action-return-type.ts b/packages/astro/test/types/action-return-type.ts new file mode 100644 index 000000000000..4ca66eed25d4 --- /dev/null +++ b/packages/astro/test/types/action-return-type.ts @@ -0,0 +1,18 @@ +import { describe, it } from 'node:test'; +import { expectTypeOf } from 'expect-type'; +import { type ActionReturnType, defineAction } from '../../dist/actions/runtime/virtual/server.js'; +import { z } from '../../zod.mjs'; + +describe('ActionReturnType', () => { + it('Infers action return type', async () => { + const action = defineAction({ + input: z.object({ + name: z.string(), + }), + handler: async ({ name }) => { + return { name }; + }, + }); + expectTypeOf>().toEqualTypeOf<{ name: string }>(); + }); +}); From 6ccb30e610eed34c2cc2c275485a8ac45c9b6b9e Mon Sep 17 00:00:00 2001 From: Ben <4991309+NuroDev@users.noreply.github.com> Date: Wed, 10 Jul 2024 12:32:56 +0100 Subject: [PATCH 07/10] feat: astro:env allow schema keys to include numbers (#11437) Co-authored-by: Florian Lefebvre --- .changeset/loud-socks-doubt.md | 5 +++ packages/astro/src/env/schema.ts | 14 +++++-- .../test/units/config/config-validate.test.js | 38 +++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 .changeset/loud-socks-doubt.md diff --git a/.changeset/loud-socks-doubt.md b/.changeset/loud-socks-doubt.md new file mode 100644 index 000000000000..4af3b4607703 --- /dev/null +++ b/.changeset/loud-socks-doubt.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes a case where Astro's config `experimental.env.schema` keys did not allow numbers. Numbers are still not allowed as the first character to be able to generate valid JavaScript identifiers diff --git a/packages/astro/src/env/schema.ts b/packages/astro/src/env/schema.ts index ec2e12827955..7686318d272f 100644 --- a/packages/astro/src/env/schema.ts +++ b/packages/astro/src/env/schema.ts @@ -81,12 +81,18 @@ const EnvFieldMetadata = z.union([ SecretServerEnvFieldMetadata, ]); -const KEY_REGEX = /^[A-Z_]+$/; +const EnvSchemaKey = z + .string() + .min(1) + .refine(([firstChar]) => isNaN(Number.parseInt(firstChar)), { + message: 'A valid variable name cannot start with a number.', + }) + .refine((str) => /^[A-Z0-9_]+$/.test(str), { + message: 'A valid variable name can only contain uppercase letters, numbers and underscores.', + }); export const EnvSchema = z.record( - z.string().regex(KEY_REGEX, { - message: 'A valid variable name can only contain uppercase letters and underscores.', - }), + EnvSchemaKey, z.intersection(EnvFieldMetadata, EnvFieldType) ); diff --git a/packages/astro/test/units/config/config-validate.test.js b/packages/astro/test/units/config/config-validate.test.js index 13574982939c..334f19aa5cad 100644 --- a/packages/astro/test/units/config/config-validate.test.js +++ b/packages/astro/test/units/config/config-validate.test.js @@ -3,6 +3,7 @@ import { describe, it } from 'node:test'; import stripAnsi from 'strip-ansi'; import { z } from 'zod'; import { validateConfig } from '../../../dist/core/config/validate.js'; +import { envField } from '../../../dist/env/config.js'; import { formatConfigErrorMessage } from '../../../dist/core/messages.js'; describe('Config Validation', () => { @@ -367,5 +368,42 @@ describe('Config Validation', () => { ).catch((err) => err) ); }); + + it('Should allow schema variables with numbers', () => { + assert.doesNotThrow(() => + validateConfig( + { + experimental: { + env: { + schema: { + ABC123: envField.string({ access: 'public', context: 'server' }), + }, + }, + }, + }, + process.cwd() + ).catch((err) => err) + ); + }); + + it('Should not allow schema variables starting with a number', async () => { + const configError = await validateConfig( + { + experimental: { + env: { + schema: { + "123ABC": envField.string({ access: 'public', context: 'server' }), + }, + }, + }, + }, + process.cwd() + ).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.errors[0].message, + 'A valid variable name cannot start with a number.' + ); + }); }); }); From 0d6f3563a5909ea80352e2fc88c8d744d6ae6cae Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 10 Jul 2024 11:33:46 +0000 Subject: [PATCH 08/10] [ci] format --- packages/astro/src/env/schema.ts | 5 +---- packages/astro/test/units/config/config-validate.test.js | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/astro/src/env/schema.ts b/packages/astro/src/env/schema.ts index 7686318d272f..4052a1c8e181 100644 --- a/packages/astro/src/env/schema.ts +++ b/packages/astro/src/env/schema.ts @@ -91,10 +91,7 @@ const EnvSchemaKey = z message: 'A valid variable name can only contain uppercase letters, numbers and underscores.', }); -export const EnvSchema = z.record( - EnvSchemaKey, - z.intersection(EnvFieldMetadata, EnvFieldType) -); +export const EnvSchema = z.record(EnvSchemaKey, z.intersection(EnvFieldMetadata, EnvFieldType)); // https://www.totaltypescript.com/concepts/the-prettify-helper type Prettify = { diff --git a/packages/astro/test/units/config/config-validate.test.js b/packages/astro/test/units/config/config-validate.test.js index 334f19aa5cad..4e4ba1e7a202 100644 --- a/packages/astro/test/units/config/config-validate.test.js +++ b/packages/astro/test/units/config/config-validate.test.js @@ -3,8 +3,8 @@ import { describe, it } from 'node:test'; import stripAnsi from 'strip-ansi'; import { z } from 'zod'; import { validateConfig } from '../../../dist/core/config/validate.js'; -import { envField } from '../../../dist/env/config.js'; import { formatConfigErrorMessage } from '../../../dist/core/messages.js'; +import { envField } from '../../../dist/env/config.js'; describe('Config Validation', () => { it('empty user config is valid', async () => { @@ -392,7 +392,7 @@ describe('Config Validation', () => { experimental: { env: { schema: { - "123ABC": envField.string({ access: 'public', context: 'server' }), + '123ABC': envField.string({ access: 'public', context: 'server' }), }, }, }, From 619f07db701ebab2d2f2598dd2dcf93ba1e5719c Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Wed, 10 Jul 2024 08:02:10 -0400 Subject: [PATCH 09/10] Actions: expose utility types (#11438) * feat: expose ACTION_ERROR_CODES util * feat: expose ActionHandler util type * chore: changeset --- .changeset/plenty-socks-talk.md | 5 +++ packages/astro/src/@types/astro.ts | 10 ++++-- .../src/actions/runtime/virtual/server.ts | 24 +++++++------- .../src/actions/runtime/virtual/shared.ts | 31 ++++++++++--------- 4 files changed, 41 insertions(+), 29 deletions(-) create mode 100644 .changeset/plenty-socks-talk.md diff --git a/.changeset/plenty-socks-talk.md b/.changeset/plenty-socks-talk.md new file mode 100644 index 000000000000..2749228dd952 --- /dev/null +++ b/.changeset/plenty-socks-talk.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Exposes utility types from `astro:actions` for the `defineAction` handler (`ActionHandler`) and the `ActionError` code (`ActionErrorCode`). diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index da0dcc9b6e9c..9a7a774c3c53 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -11,7 +11,11 @@ import type { import type * as babel from '@babel/core'; import type * as rollup from 'rollup'; import type * as vite from 'vite'; -import type { Accept, ActionClient, InputSchema } from '../actions/runtime/virtual/server.js'; +import type { + ActionAccept, + ActionClient, + ActionInputSchema, +} from '../actions/runtime/virtual/server.js'; import type { RemotePattern } from '../assets/utils/remotePattern.js'; import type { AssetsPrefix, SerializedSSRManifest } from '../core/app/types.js'; import type { PageBuildData } from '../core/build/types.js'; @@ -2764,8 +2768,8 @@ interface AstroSharedContext< * Get action result on the server when using a form POST. */ getActionResult: < - TAccept extends Accept, - TInputSchema extends InputSchema, + TAccept extends ActionAccept, + TInputSchema extends ActionInputSchema, TAction extends ActionClient, >( action: TAction diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts index c034be8bc8d8..8f0d6e111dbf 100644 --- a/packages/astro/src/actions/runtime/virtual/server.ts +++ b/packages/astro/src/actions/runtime/virtual/server.ts @@ -10,12 +10,12 @@ export { z } from 'zod'; /** @deprecated Access context from the second `handler()` parameter. */ export const getApiContext = _getApiContext; -export type Accept = 'form' | 'json'; -export type InputSchema = T extends 'form' +export type ActionAccept = 'form' | 'json'; +export type ActionInputSchema = T extends 'form' ? z.AnyZodObject | z.ZodType : z.ZodType; -type Handler = TInputSchema extends z.ZodType +export type ActionHandler = TInputSchema extends z.ZodType ? (input: z.infer, context: ActionAPIContext) => MaybePromise : (input: any, context: ActionAPIContext) => MaybePromise; @@ -23,8 +23,8 @@ export type ActionReturnType> = Awaited | undefined, + TAccept extends ActionAccept | undefined, + TInputSchema extends ActionInputSchema | undefined, > = TInputSchema extends z.ZodType ? (( input: TAccept extends 'form' ? FormData : z.input @@ -46,8 +46,8 @@ export type ActionClient< export function defineAction< TOutput, - TAccept extends Accept | undefined = undefined, - TInputSchema extends InputSchema | undefined = TAccept extends 'form' + TAccept extends ActionAccept | undefined = undefined, + TInputSchema extends ActionInputSchema | undefined = TAccept extends 'form' ? // If `input` is omitted, default to `FormData` for forms and `any` for JSON. z.ZodType : undefined, @@ -58,7 +58,7 @@ export function defineAction< }: { input?: TInputSchema; accept?: TAccept; - handler: Handler; + handler: ActionHandler; }): ActionClient { const serverHandler = accept === 'form' @@ -73,8 +73,8 @@ export function defineAction< return serverHandler as ActionClient; } -function getFormServerHandler>( - handler: Handler, +function getFormServerHandler>( + handler: ActionHandler, inputSchema?: TInputSchema ) { return async (unparsedInput: unknown): Promise> => { @@ -95,8 +95,8 @@ function getFormServerHandler> }; } -function getJsonServerHandler>( - handler: Handler, +function getJsonServerHandler>( + handler: ActionHandler, inputSchema?: TInputSchema ) { return async (unparsedInput: unknown): Promise> => { diff --git a/packages/astro/src/actions/runtime/virtual/shared.ts b/packages/astro/src/actions/runtime/virtual/shared.ts index 94a22f7ca388..10ed75aa5174 100644 --- a/packages/astro/src/actions/runtime/virtual/shared.ts +++ b/packages/astro/src/actions/runtime/virtual/shared.ts @@ -1,20 +1,23 @@ import type { z } from 'zod'; import type { ErrorInferenceObject, MaybePromise } from '../utils.js'; -type ActionErrorCode = - | 'BAD_REQUEST' - | 'UNAUTHORIZED' - | 'FORBIDDEN' - | 'NOT_FOUND' - | 'TIMEOUT' - | 'CONFLICT' - | 'PRECONDITION_FAILED' - | 'PAYLOAD_TOO_LARGE' - | 'UNSUPPORTED_MEDIA_TYPE' - | 'UNPROCESSABLE_CONTENT' - | 'TOO_MANY_REQUESTS' - | 'CLIENT_CLOSED_REQUEST' - | 'INTERNAL_SERVER_ERROR'; +export const ACTION_ERROR_CODES = [ + 'BAD_REQUEST', + 'UNAUTHORIZED', + 'FORBIDDEN', + 'NOT_FOUND', + 'TIMEOUT', + 'CONFLICT', + 'PRECONDITION_FAILED', + 'PAYLOAD_TOO_LARGE', + 'UNSUPPORTED_MEDIA_TYPE', + 'UNPROCESSABLE_CONTENT', + 'TOO_MANY_REQUESTS', + 'CLIENT_CLOSED_REQUEST', + 'INTERNAL_SERVER_ERROR', +] as const; + +export type ActionErrorCode = (typeof ACTION_ERROR_CODES)[number]; const codeToStatusMap: Record = { // Implemented from tRPC error code table From 88e2b43305c07ef8b489081a5a580584cbd7f342 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Wed, 10 Jul 2024 08:11:39 -0400 Subject: [PATCH 10/10] fix: bad Handler type (#11448) --- packages/astro/src/actions/runtime/virtual/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts index 8f0d6e111dbf..326bbf4f9be6 100644 --- a/packages/astro/src/actions/runtime/virtual/server.ts +++ b/packages/astro/src/actions/runtime/virtual/server.ts @@ -19,7 +19,7 @@ export type ActionHandler = TInputSchema extends z.ZodTyp ? (input: z.infer, context: ActionAPIContext) => MaybePromise : (input: any, context: ActionAPIContext) => MaybePromise; -export type ActionReturnType> = Awaited>; +export type ActionReturnType> = Awaited>; export type ActionClient< TOutput,