diff --git a/.changeset/dull-mayflies-flash.md b/.changeset/dull-mayflies-flash.md new file mode 100644 index 00000000..7366167d --- /dev/null +++ b/.changeset/dull-mayflies-flash.md @@ -0,0 +1,5 @@ +--- +'@inox-tools/astro-tests': patch +--- + +Export `TestApp` type diff --git a/.changeset/giant-months-search.md b/.changeset/giant-months-search.md new file mode 100644 index 00000000..9cd3d656 --- /dev/null +++ b/.changeset/giant-months-search.md @@ -0,0 +1,5 @@ +--- +'@inox-tools/cut-short': minor +--- + +Implement cut-short integration diff --git a/.changeset/wild-dancers-juggle.md b/.changeset/wild-dancers-juggle.md new file mode 100644 index 00000000..48d435f7 --- /dev/null +++ b/.changeset/wild-dancers-juggle.md @@ -0,0 +1,5 @@ +--- +'@inox-tools/utils': patch +--- + +Add `MaybePromise`, `MaybeFactory` and `MaybeThunk` utility types. diff --git a/.github/labeler.yml b/.github/labeler.yml index ccc4b886..a47d72fb 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -10,6 +10,9 @@ workspace: ## PACKAGES +pkg/cut-short: +- 'packages/cut-short/**' + pkg/request-nanostores: - 'packages/request-nanostores/**' diff --git a/docs/astro.config.ts b/docs/astro.config.ts index b73127c7..28c098d4 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -60,14 +60,14 @@ export default defineConfig({ { label: 'Request State', link: '/request-state', - badge: { - text: 'NEW', - variant: 'success', - }, }, { label: 'Request Nanostores', link: '/request-nanostores', + }, + { + label: 'Cut Short', + link: '/cut-short', badge: { text: 'NEW', variant: 'success', diff --git a/docs/src/content/docs/content-utils/git.mdx b/docs/src/content/docs/content-utils/git.mdx index 58f42ef1..369bef2f 100644 --- a/docs/src/content/docs/content-utils/git.mdx +++ b/docs/src/content/docs/content-utils/git.mdx @@ -2,9 +2,6 @@ title: Git Info packageName: '@inox-tools/content-utils' description: Get creation and update time for your content from your git history. -sidebar: - badge: - text: Updated --- import { Steps } from '@astrojs/starlight/components'; diff --git a/docs/src/content/docs/cut-short.mdx b/docs/src/content/docs/cut-short.mdx new file mode 100644 index 00000000..0d2a109e --- /dev/null +++ b/docs/src/content/docs/cut-short.mdx @@ -0,0 +1,116 @@ +--- +title: Cut-Short Requests +--- + +Cut-short is an Astro integration that lets you stop processing a request instantly and send back a custom response, simplifying control flow in your Astro applications. By introducing the `endRequest` function, it eliminates the need for cumbersome workarounds like bubbling up response objects, throwing and catching sentinel errors, implementing custom middleware logic or replicating error response logic across all your pages. + +Keep the the custom response for specific conditions close to the conditions and have it shared across all your application. It's especially useful for scenarios like user authentication and access control, where you might need to redirect users to sign-in page from anywhere that requires authentication or to turn any page they don't have access to into a 404 to avoid information leak (like GitHub does). + +## Installing the integration + +import { PackageManagers } from 'starlight-package-managers'; + + + +## How to use + +From any code that is reachable from a page rendering context can use the `endRequest` function to stop the. + +A page-rendering context is when you are inside of: + +- A middleware; +- The frontmatter of a page component (not components _in_ the page, see [streaming](#streaming)); +- An API endpoint; +- A function called from another page-rendering context. + +import { Tabs, TabItem } from '@astrojs/starlight/components'; + + + + +```ts title="src/lib/auth.js" +import { endRequest } from '@it-astro/cut-short'; + +export function getUser() { + if (noUser) endRequest(new Response('No user', { status: 401 })); + + return {...}; +} +``` + + + + +```astro title="src/pages/dashboard.astro" +--- +import { getUser } from '../lib/auth'; + +// Calling a function from a rendering context allows it to stop the request. +const user = getUser(); +--- + + +``` + + + + +```ts title="src/pages/userId.ts" +import { endRequest } from '@it-astro/cut-short'; +import { getUser } from '../lib/auth'; + +export const GET = () => { + if (someCondition) endRequest(new Response('Matched inline blocking condition')); + + // Calling a function from a rendering context allows it to stop the request. + const user = getUser(); + + return new Response(user.id); +}; +``` + + + + +### `endRequest()` + +**Type:** `(withResponse: Response | (() => Response | Promise)) => void` + +Stop the current request and send back a custom response. + +The argument can be a `Response` object to be used directly or a function that returns a `Response` object or a promise that resolves to a `Response`. + +## Streaming + +Once Astro executes the frontmatter of the page component, the HTML response is streamed to the client _as it is rendered_. This means that when the frontmatter of components deep in the page is executed the response has already been partially sent. In the example below, when `MyComponent` is executed, the response has already been constructed and is being streamed. + +```astro title="src/pages/index.astro" +--- +import MyComponent from '../components/MyComponent.astro'; +--- + + + + Example + + + + + +``` + +This prevents components from changing the status code of the response and from completely switching the response under some condition. For that reason, calling `endRequest` from a component _in_ the page is not allowed, just like returning a response: + +```astro title="src/components/MyComponent.astro" +--- +import { endRequest } from '@it-astro/cut-short'; + +// Neither of these is allowed +endRequest(new Response('Page not found', { status: 404 })); +return new Response('Page not found', { status: 404 }); +--- +``` + +## License + +Cut-Short Requests is available under the MIT license. diff --git a/examples/sitemap-ext/src/auth.ts b/examples/sitemap-ext/src/auth.ts new file mode 100644 index 00000000..a62f34fc --- /dev/null +++ b/examples/sitemap-ext/src/auth.ts @@ -0,0 +1,22 @@ +import type { AstroGlobal } from 'astro'; +import { endRequest } from '@it-astro:cut-short'; + +export function getUser(Astro: AstroGlobal): { id: string; permissions: string[] } { + const cookie = Astro.cookies.get('username'); + if (cookie === undefined) { + endRequest(Astro.redirect('/signin')); + } + + return { + id: cookie.value, + permissions: [], + }; +} + +export function validateUserPermisssion(Astro: AstroGlobal, permission: string): void { + const user = getUser(Astro); + + if (!user.permissions.includes(permission)) { + endRequest(Astro.redirect('/404')); + } +} diff --git a/packages/astro-tests/src/astroFixture.ts b/packages/astro-tests/src/astroFixture.ts index 43caf9ca..00a4f62d 100644 --- a/packages/astro-tests/src/astroFixture.ts +++ b/packages/astro-tests/src/astroFixture.ts @@ -23,7 +23,7 @@ export type NodeResponse = import('node:http').ServerResponse; export type DevServer = Awaited>; export type PreviewServer = Awaited>; -type TestApp = { +export type TestApp = { render: (req: Request) => Promise; toInternalApp: () => App; }; @@ -224,9 +224,9 @@ export async function loadFixture(inlineConfig: InlineConfig): Promise const onNextChange = () => devServer ? new Promise((resolve) => - // TODO: Implement filter to only resolve on changes to a given file. - devServer.watcher.once('change', () => resolve()) - ) + // TODO: Implement filter to only resolve on changes to a given file. + devServer.watcher.once('change', () => resolve()) + ) : Promise.reject(new Error('No dev server running')); // Also do it on process exit, just in case. diff --git a/packages/cut-short/README.md b/packages/cut-short/README.md new file mode 100644 index 00000000..293ebc00 --- /dev/null +++ b/packages/cut-short/README.md @@ -0,0 +1,25 @@ +

+ InoxTools +

+ +# Cut Short + +Immediately halt request processing and return custom responses effortlessly. + +## Install + +```js +npm i @inox-tools/cut-short +``` + +Add the integration to your `astro.config.mjs`: + +```js +// astro.config.mjs +import { defineConfig } from 'astro' +import cutShort from '@inox-tools/cut-short'; + +export default defineConfig({ + integrations: [cutShort({})] +}) +``` diff --git a/packages/cut-short/npmignore b/packages/cut-short/npmignore new file mode 100644 index 00000000..d433a553 --- /dev/null +++ b/packages/cut-short/npmignore @@ -0,0 +1,3 @@ +node_modules +*.log +src \ No newline at end of file diff --git a/packages/cut-short/package.json b/packages/cut-short/package.json new file mode 100644 index 00000000..ab7adfed --- /dev/null +++ b/packages/cut-short/package.json @@ -0,0 +1,51 @@ +{ + "name": "@inox-tools/cut-short", + "version": "0.0.0", + "description": "Immediately halt request processing and return custom responses effortlessly.", + "keywords": [ + "astro-integration", + "withastro", + "astro" + ], + "license": "MIT", + "author": "Luiz Ferraz ", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "src", + "virtual.d.ts" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "prepublish": "pnpm run build", + "test": "vitest run --coverage", + "test:dev": "vitest --coverage.enabled=true" + }, + "dependencies": { + "@inox-tools/utils": "workspace:^", + "astro-integration-kit": "catalog:", + "debug": "catalog:" + }, + "devDependencies": { + "@inox-tools/astro-tests": "workspace:", + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "@vitest/ui": "catalog:", + "jest-extended": "catalog:", + "astro": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "astro": "catalog:lax" + } +} diff --git a/packages/cut-short/src/index.ts b/packages/cut-short/src/index.ts new file mode 100644 index 00000000..7d7c6ded --- /dev/null +++ b/packages/cut-short/src/index.ts @@ -0,0 +1,45 @@ +import { defineIntegration, addVitePlugin, createResolver } from 'astro-integration-kit'; +import { z } from 'astro/zod'; +import { debug } from './internal/debug.js'; + +export default defineIntegration({ + name: '@inox-tools/cut-short', + optionsSchema: z.never().optional(), + setup() { + const { resolve } = createResolver(import.meta.url); + + return { + hooks: { + 'astro:config:setup': (params) => { + params.addMiddleware({ + entrypoint: resolve('./runtime/middleware.js'), + order: 'post', + }); + + addVitePlugin(params, { + warnDuplicated: true, + plugin: { + name: '@inox-tools/cut-short', + enforce: 'pre', + resolveId(source) { + if (source === '@it-astro:cut-short') { + return resolve('./runtime/entrypoint.js'); + } + }, + }, + }); + }, + 'astro:config:done': (params) => { + // Check if the version of Astro being used has the `injectTypes` utility. + if (typeof params.injectTypes === 'function') { + debug('Injecting types in .astro structure'); + params.injectTypes({ + filename: 'types.d.ts', + content: "import '@inox-tools/cut-short';", + }); + } + }, + }, + }; + }, +}); diff --git a/packages/cut-short/src/internal/carrier.ts b/packages/cut-short/src/internal/carrier.ts new file mode 100644 index 00000000..2317dad9 --- /dev/null +++ b/packages/cut-short/src/internal/carrier.ts @@ -0,0 +1,11 @@ +import { loadThunkValue, type MaybeThunk } from '@inox-tools/utils/values'; + +export class CarrierError extends Error { + public constructor(private readonly response: MaybeThunk) { + super('CarrierError'); + } + + public getResponse(): Promise { + return Promise.resolve(loadThunkValue(this.response)); + } +} diff --git a/packages/cut-short/src/internal/debug.ts b/packages/cut-short/src/internal/debug.ts new file mode 100644 index 00000000..408f385a --- /dev/null +++ b/packages/cut-short/src/internal/debug.ts @@ -0,0 +1,7 @@ +import debugC from 'debug'; + +export const debug = debugC('inox-tools:cut-short') + +export const getDebug = (segment?: string) => { + return segment ? debug.extend(segment) : debug; +} diff --git a/packages/cut-short/src/runtime/entrypoint.ts b/packages/cut-short/src/runtime/entrypoint.ts new file mode 100644 index 00000000..96158382 --- /dev/null +++ b/packages/cut-short/src/runtime/entrypoint.ts @@ -0,0 +1,6 @@ +import type { MaybeThunk } from '@inox-tools/utils/values'; +import { CarrierError } from '../internal/carrier.js'; + +export const endRequest = (withResponse: MaybeThunk): never => { + throw new CarrierError(withResponse); +}; diff --git a/packages/cut-short/src/runtime/middleware.ts b/packages/cut-short/src/runtime/middleware.ts new file mode 100644 index 00000000..cdd36523 --- /dev/null +++ b/packages/cut-short/src/runtime/middleware.ts @@ -0,0 +1,16 @@ +import type { MiddlewareHandler } from 'astro'; +import { debug } from '../internal/debug.js'; +import { CarrierError } from '../internal/carrier.js'; + +export const onRequest: MiddlewareHandler = async (_, next) => { + try { + return await next(); + } catch (err: unknown) { + if (err instanceof CarrierError) { + debug('Returning response from CarrierError'); + return err.getResponse(); + } + + throw err; + } +}; diff --git a/packages/cut-short/tests/basic.test.ts b/packages/cut-short/tests/basic.test.ts new file mode 100644 index 00000000..b9e99112 --- /dev/null +++ b/packages/cut-short/tests/basic.test.ts @@ -0,0 +1,25 @@ +import { loadFixture, type TestApp } from '@inox-tools/astro-tests/astroFixture'; +import testAdapter from '@inox-tools/astro-tests/testAdapter'; +import { beforeAll, expect, test } from 'vitest'; + +const fixture = await loadFixture({ + root: './fixture/basic', + output: 'server', + adapter: testAdapter(), +}); + +let app: TestApp; + +beforeAll(async () => { + await fixture.build({}); + app = await fixture.loadTestAdapterApp(); +}); + +test('ending request on page frontmatter', async () => { + const res = await app.render(new Request('https://example.com/')); + + expect(res.headers.get('Content-Type')).toEqual('application/json'); + + const content = await res.json(); + expect(content).toEqual({ cutShort: true }); +}); diff --git a/packages/cut-short/tests/fixture/basic/astro.config.ts b/packages/cut-short/tests/fixture/basic/astro.config.ts new file mode 100644 index 00000000..15696549 --- /dev/null +++ b/packages/cut-short/tests/fixture/basic/astro.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'astro/config'; +import cutShort from '@inox-tools/cut-short'; + +export default defineConfig({ + integrations: [cutShort()], +}); diff --git a/packages/cut-short/tests/fixture/basic/package.json b/packages/cut-short/tests/fixture/basic/package.json new file mode 100644 index 00000000..607620c4 --- /dev/null +++ b/packages/cut-short/tests/fixture/basic/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cut-short/basic", + "private": true, + "type": "module", + "dependencies": { + "@inox-tools/cut-short": "workspace:", + "astro": "catalog:" + } +} diff --git a/packages/cut-short/tests/fixture/basic/src/pages/index.astro b/packages/cut-short/tests/fixture/basic/src/pages/index.astro new file mode 100644 index 00000000..0f57e03d --- /dev/null +++ b/packages/cut-short/tests/fixture/basic/src/pages/index.astro @@ -0,0 +1,7 @@ +--- +import { endRequest } from '@it-astro:cut-short'; + +endRequest(Response.json({ cutShort: true })); +--- + +
You shouldn't see this
diff --git a/packages/cut-short/tests/vitest.setup.ts b/packages/cut-short/tests/vitest.setup.ts new file mode 100644 index 00000000..fe21fbed --- /dev/null +++ b/packages/cut-short/tests/vitest.setup.ts @@ -0,0 +1,4 @@ +import * as matchers from 'jest-extended'; +import { expect } from 'vitest'; + +expect.extend(matchers); diff --git a/packages/cut-short/tsconfig.json b/packages/cut-short/tsconfig.json new file mode 100644 index 00000000..bfc6df51 --- /dev/null +++ b/packages/cut-short/tsconfig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json" +} diff --git a/packages/cut-short/tsup.config.ts b/packages/cut-short/tsup.config.ts new file mode 100644 index 00000000..d768d323 --- /dev/null +++ b/packages/cut-short/tsup.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'tsup'; + +import { readFileSync } from 'node:fs'; + +const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8')); +const dependencies = [ + ...Object.keys(packageJson.dependencies || {}), + ...Object.keys(packageJson.peerDependencies || {}), +]; +const devDependencies = [...Object.keys(packageJson.devDependencies || {})]; + +export default defineConfig({ + entry: ['src/index.ts', 'src/runtime/*.ts'], + format: ['esm'], + target: 'node18', + bundle: true, + dts: { + entry: ['src/index.ts'], + banner: '/// \n', + }, + sourcemap: true, + clean: true, + splitting: true, + minify: false, + external: [...dependencies, './virtual.d.ts'], + noExternal: devDependencies, + treeshake: 'smallest', + tsconfig: 'tsconfig.json', + esbuildOptions: (options) => { + options.chunkNames = 'chunks/[name]-[hash]'; + }, +}); diff --git a/packages/cut-short/virtual.d.ts b/packages/cut-short/virtual.d.ts new file mode 100644 index 00000000..d3a7f6da --- /dev/null +++ b/packages/cut-short/virtual.d.ts @@ -0,0 +1,5 @@ +declare module '@it-astro:cut-short' { + import type { MaybeThunk } from '@inox-tools/utils/values'; + + export const endRequest: (withResponse: MaybeThunk) => never; +} diff --git a/packages/cut-short/vitest.config.mjs b/packages/cut-short/vitest.config.mjs new file mode 100644 index 00000000..ff003504 --- /dev/null +++ b/packages/cut-short/vitest.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + test: { + setupFiles: ['./tests/vitest.setup.ts'], + }, +}); diff --git a/packages/utils/src/values.ts b/packages/utils/src/values.ts new file mode 100644 index 00000000..0724f8fa --- /dev/null +++ b/packages/utils/src/values.ts @@ -0,0 +1,42 @@ +/** + * A value that can be might be pending to be resolved. + */ +export type MaybePromise = T | Promise; + +/** + * A value or a thunk for a value. + * + * A "thunk" is a function that takes no arguments and return + * a value that is potentially expensive to compute. + * This can be used when the value might not be needed and as + * such can be computed on demand potentially saving the + * expensive computation. + * + * If the value is not expensive to compute it can be used directly + * for simplicity. + * + * A value type that is itself a function cannot be a "maybe" thunk. + * + * @see https://en.wikipedia.org/wiki/Thunk + */ +export type MaybeThunk = T extends Function ? never : T | (() => T); + +/** + * A value or a thunk for a synchronous or asynchronous value. + * + * @see MaybePromise + * @see MaybeThunk + */ +export type MaybeAsyncThunk = MaybeThunk>; + +/** + * Load a value from a possibly thunk argument. + * + * If the value is a thunk it is called and the result is returned. + * Otherwise the value itself is returned. + * + * @see MaybeThunk + */ +export function loadThunkValue(value: MaybeThunk): T { + return typeof value === 'function' ? value() : value; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cb59937..fb299a23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -621,6 +621,58 @@ importers: specifier: 'catalog:' version: 5.5.4 + packages/cut-short: + dependencies: + '@inox-tools/utils': + specifier: workspace:^ + version: link:../utils + astro-integration-kit: + specifier: 'catalog:' + version: 0.16.1(astro@4.15.3(@types/node@22.5.4)(rollup@4.21.2)(typescript@5.5.4)) + debug: + specifier: 'catalog:' + version: 4.3.7 + devDependencies: + '@inox-tools/astro-tests': + specifier: 'workspace:' + version: link:../astro-tests + '@types/node': + specifier: 'catalog:' + version: 22.5.4 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 2.0.5(vitest@2.0.5(@types/node@22.5.4)(@vitest/ui@2.0.5)) + '@vitest/ui': + specifier: 'catalog:' + version: 2.0.5(vitest@2.0.5) + astro: + specifier: 'catalog:' + version: 4.15.3(@types/node@22.5.4)(rollup@4.21.2)(typescript@5.5.4) + jest-extended: + specifier: 'catalog:' + version: 4.0.2 + tsup: + specifier: 'catalog:' + version: 8.2.4(jiti@1.21.6)(postcss@8.4.45)(typescript@5.5.4)(yaml@2.5.1) + typescript: + specifier: 'catalog:' + version: 5.5.4 + vite: + specifier: 'catalog:' + version: 5.4.3(@types/node@22.5.4) + vitest: + specifier: 'catalog:' + version: 2.0.5(@types/node@22.5.4)(@vitest/ui@2.0.5) + + packages/cut-short/tests/fixture/basic: + dependencies: + '@inox-tools/cut-short': + specifier: 'workspace:' + version: link:../../.. + astro: + specifier: 'catalog:' + version: 4.15.3(@types/node@22.5.4)(rollup@4.21.2)(typescript@5.5.4) + packages/inline-mod: dependencies: '@inox-tools/utils':