diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 49bfd4c..a2bf7e3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,6 +1,10 @@ # https://help.github.com/en/categories/automating-your-workflow-with-github-actions # See: https://github.com/JulianCataldo/gh-actions +# For matrix setup: +# https://github.com/withastro/astro/blob/main/.github/workflows/ci.yml +# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/running-variations-of-jobs-in-a-workflow + name: CI / Release on: @@ -28,13 +32,31 @@ permissions: jobs: release: name: CI / Release - runs-on: ubuntu-latest + permissions: contents: write # to be able to publish a GitHub release issues: write # to be able to comment on released issues pull-requests: write # to be able to comment on released pull requests id-token: write # to enable use of OIDC for npm provenance + runs-on: ubuntu-latest + # TODO: + # runs-on: ${{ matrix.os }} + # timeout-minutes: 25 + # # needs: build + # strategy: + # matrix: + # OS: [ubuntu-latest] + # NODE_VERSION: [18, 20] + # include: + # - os: macos-14 + # NODE_VERSION: 18 + # - os: windows-latest + # NODE_VERSION: 18 + # fail-fast: false + # env: + # NODE_VERSION: ${{ matrix.NODE_VERSION }} + steps: # MARK: Setup GH Action diff --git a/integration/__fixtures__/server-express/dist_expected/server/chunk/basic.js b/integration/__fixtures__/server-express/dist_expected/server/chunk/basic.js index ba41b31..60b93ba 100644 --- a/integration/__fixtures__/server-express/dist_expected/server/chunk/basic.js +++ b/integration/__fixtures__/server-express/dist_expected/server/chunk/basic.js @@ -1,5 +1,5 @@ import { defineRoute } from '@gracile/gracile/route'; -import * as Route from '@gracile/gracile/_internals/route'; +import { RequestMethod } from '@gracile/gracile/_internals/route'; const basic = defineRoute({ handler: { @@ -8,7 +8,7 @@ const basic = defineRoute({ { url, param1: url.searchParams.get("foo"), - [Route.RequestMethod.GET]: "ok", + [RequestMethod.GET]: "ok", // TODO: When middleware are implemented, mock this properly locals: { requestIdLength: locals.requestId.length } }, @@ -26,7 +26,7 @@ const basic = defineRoute({ { url, param1: url.searchParams.get("foo"), - [Route.RequestMethod.POST]: "ok", + [RequestMethod.POST]: "ok", body: await request.json(), locals: { requestIdLength: locals.requestId.length } }, @@ -38,7 +38,7 @@ const basic = defineRoute({ { url, param1: url.searchParams.get("foo"), - [Route.RequestMethod.PUT]: "ok", + [RequestMethod.PUT]: "ok", body: await request.json(), locals: { requestIdLength: locals.requestId.length } }, @@ -50,7 +50,7 @@ const basic = defineRoute({ { url, param1: url.searchParams.get("foo"), - [Route.RequestMethod.QUERY]: "ok", + [RequestMethod.QUERY]: "ok", body: await request.json(), locals: { requestIdLength: locals.requestId.length } }, @@ -62,7 +62,7 @@ const basic = defineRoute({ { url, param1: url.searchParams.get("foo"), - [Route.RequestMethod.DELETE]: "ok", + [RequestMethod.DELETE]: "ok", locals: { requestIdLength: locals.requestId.length } }, { status: 200, statusText: "DONE", headers: { bar: "baz" } } @@ -73,7 +73,7 @@ const basic = defineRoute({ { url, param1: url.searchParams.get("foo"), - [Route.RequestMethod.PATCH]: "ok", + [RequestMethod.PATCH]: "ok", body: await request.json(), locals: { requestIdLength: locals.requestId.length } }, diff --git a/integration/__fixtures__/server-express/dist_expected/server/chunk/route-premises.js b/integration/__fixtures__/server-express/dist_expected/server/chunk/route-premises.js index a8878c9..804cbc1 100644 --- a/integration/__fixtures__/server-express/dist_expected/server/chunk/route-premises.js +++ b/integration/__fixtures__/server-express/dist_expected/server/chunk/route-premises.js @@ -23,20 +23,6 @@ options) { return routeModule; }; } -// TODO: remove this, use `Response.json instead`? Or keep for old envs? -// export function jsonResponse(data: any, init?: ResponseInit) { -// return new Response(JSON.stringify(data), { -// ...init, -// headers: { 'Content-Type': 'application/json' }, -// }); -// } -// NOTE: Useful? -// export function notFound(statusText = '404 - Not found') { -// return new Response(null, { -// status: 404, -// statusText, -// }); -// } const routeImports = new Map( [ diff --git a/integration/__fixtures__/server-express/dist_expected/server/entrypoint.js b/integration/__fixtures__/server-express/dist_expected/server/entrypoint.js index a5d286c..d6d6b4d 100644 --- a/integration/__fixtures__/server-express/dist_expected/server/entrypoint.js +++ b/integration/__fixtures__/server-express/dist_expected/server/entrypoint.js @@ -1,5 +1,6 @@ import { URLPattern } from '@gracile/gracile/url-pattern'; import { createGracileHandler } from '@gracile/gracile/_internals/server-runtime'; +import { createLogger } from '@gracile/gracile/_internals/logger'; const routes = new Map([ [ @@ -204,6 +205,8 @@ const routeAssets = new Map([ ] ]); +createLogger(); + const handler = createGracileHandler({ root: process.cwd(), routes, diff --git a/integration/__fixtures__/static-site/src/routes/_throws_expected.html b/integration/__fixtures__/static-site/src/routes/_throws_expected.html index baa848e..4c85a98 100644 --- a/integration/__fixtures__/static-site/src/routes/_throws_expected.html +++ b/integration/__fixtures__/static-site/src/routes/_throws_expected.html @@ -1,62 +1,20 @@ - - - - - - Error - - + 500: TypeError | Internal Server Error - -
-

šŸ˜µ An error has occurred!

- - -
- -
-Error: !!! OH NO !!! I AM A FAKE ERROR !!!
-    at RouteModule.template (__REPLACED_FOR_TESTS__)
-    at renderRouteTemplate (__REPLACED_FOR_TESTS__)
-    at async middleware (__REPLACED_FOR_TESTS__)
-    at async nodeHandler (__REPLACED_FOR_TESTS__)
- - - -
-
- - + + + +

500: TypeError | Internal Server Error

diff --git a/integration/__fixtures__/static-site/src/routes/throws.ts b/integration/__fixtures__/static-site/src/routes/throws.ts index 8c37ba2..523e1f4 100644 --- a/integration/__fixtures__/static-site/src/routes/throws.ts +++ b/integration/__fixtures__/static-site/src/routes/throws.ts @@ -1,5 +1,6 @@ import { defineRoute } from '@gracile/gracile/route'; import { html } from '@gracile/gracile/server-html'; +// import { GracileUserError } from '@gracile/gracile/error'; import { document } from '../documents/document-minimal.js'; @@ -7,7 +8,7 @@ export default defineRoute({ document: (context) => document({ ...context, title: 'Gracile - Oh no' }), template: (context) => { - throw new Error('!!! OH NO !!! I AM A FAKE ERROR !!!'); + throw new TypeError('!!! OH NO !!! I AM A FAKE ERROR !!!'); html`

āš ļø Arrrrrhh !!

diff --git a/integration/manual-tests.sh b/integration/manual-tests.sh index efaf2c4..a99992d 100644 --- a/integration/manual-tests.sh +++ b/integration/manual-tests.sh @@ -1,4 +1,7 @@ # pnpm tsx --test -C test src/addons/client-router.test.ts + +# set -e + pnpm tsx --test -C test src/routes-premises.test.ts pnpm tsx --test -C test src/addons/metadata.test.ts diff --git a/integration/src/exports.test.ts b/integration/src/exports.test.ts index e46f8d7..56a10c4 100644 --- a/integration/src/exports.test.ts +++ b/integration/src/exports.test.ts @@ -9,6 +9,8 @@ import { fileURLToPath } from 'node:url'; import { constants as serverConstants } from '@gracile/engine/server/constants'; import * as internalLogger from '@gracile/gracile/_internals/logger'; +import * as route from '@gracile/gracile/_internals/route'; +import * as routeModule from '@gracile/gracile/_internals/route-module'; import * as serverRuntime from '@gracile/gracile/_internals/server-runtime'; import * as document from '@gracile/gracile/document'; import { env as envFromNodeConditions } from '@gracile/gracile/env'; @@ -150,5 +152,8 @@ describe('gracile package should do its exports correctly', () => { test('internals', () => { assert.equal(typeof internalLogger.createLogger, 'function'); + assert.equal(typeof route.RequestMethod, 'object'); + assert.equal(typeof routeModule.RouteModule, 'function'); + assert.equal(typeof serverRuntime.createGracileHandler, 'function'); }); }); diff --git a/integration/src/server-express/_common.ts b/integration/src/server-express/_common.ts index f416a06..77fcf95 100644 --- a/integration/src/server-express/_common.ts +++ b/integration/src/server-express/_common.ts @@ -151,13 +151,15 @@ async function tests(mode: string, item: string, writeActual: boolean) { )); // TODO: Test with "accept: json" when implemented - await it(`load an error page when a route throws - ${item}`, async () => + await it(`load an error page when a route throws - ${item}`, async () => { + const ressource = await fetchResource(ADDRESS, ['throws']); + assert.equal( - (await fetchResource(ADDRESS, ['throws'])).includes( - 'Error: !!! OH NO !!! I AM A FAKE ERROR !!!', - ), + // should take over just after (its a client only component) in DEV + ressource.includes('500: Error | Internal Server Error'), true, - )); + ); + }); await it(`should redirect`, async () => checkResponse( diff --git a/packages/engine/src/dev/dev.ts b/packages/engine/src/dev/dev.ts index 999bcee..651dbad 100644 --- a/packages/engine/src/dev/dev.ts +++ b/packages/engine/src/dev/dev.ts @@ -2,7 +2,7 @@ import { getLogger } from '@gracile/internal-utils/logger/helpers'; import c from 'picocolors'; import { type ViteDevServer } from 'vite'; -import { collectRoutes } from '../routes/collect.js'; +import { collectRoutes, WATCHED_FILES_REGEX } from '../routes/collect.js'; import type { RoutesManifest } from '../routes/route.js'; import { createGracileHandler, @@ -14,7 +14,7 @@ import { generateRoutesTypings } from './route-typings.js'; export async function createDevHandler({ routes, vite, - gracileConfig,dd + gracileConfig, }: { routes: RoutesManifest; vite: ViteDevServer; @@ -27,34 +27,36 @@ export async function createDevHandler({ const root = vite.config.root; - logger.info(c.dim('\nCreating handlerā€¦'), { timestamp: true }); + logger.info(''); + logger.info(c.dim('Creating the request handlerā€¦'), { timestamp: true }); - await collectRoutes(routes, root, gracileConfig.routes?.exclude); + const collect = async () => { + await collectRoutes(routes, root, gracileConfig.routes?.exclude); - if (gracileConfig.experimental?.generateRoutesTypings) - generateRoutesTypings(root, routes).catch((error) => - logger.error(String(error)), - ); + if (gracileConfig.experimental?.generateRoutesTypings) + await generateRoutesTypings(root, routes).catch((error) => + logger.error(String(error)), + ); + }; + await collect(); + + let wait: ReturnType; vite.watcher.on('all', (event, file) => { - // console.log({ event }); if ( - file.match( - /\/src\/routes\/(.*)\.(ts|js|css|scss|sass|less|styl|stylus)$/, - ) && + file.match(WATCHED_FILES_REGEX) && ['add', 'unlink'].includes(event) - ) - collectRoutes(routes, root, gracileConfig.routes?.exclude) - .then(() => { - vite.hot.send('vite:invalidate'); - - if (gracileConfig.experimental?.generateRoutesTypings) - generateRoutesTypings(root, routes).catch((error) => - logger.error(String(error)), - ); - }) - .catch((e) => logger.error(String(e))); + // + ) { + clearTimeout(wait); + wait = setTimeout(() => { + collect() + .then(() => vite.hot.send('vite:invalidate')) + .catch((error) => logger.error(String(error))); + }, 100); + } }); + // // NOTE: Wrong place? diff --git a/packages/engine/src/errors/create-vite-better-error.ts b/packages/engine/src/errors/create-vite-better-error.ts new file mode 100644 index 0000000..f83c83c --- /dev/null +++ b/packages/engine/src/errors/create-vite-better-error.ts @@ -0,0 +1,50 @@ +import { pathToFileURL } from 'node:url'; + +import { getLogger } from '@gracile/internal-utils/logger/helpers'; +import { formatErrorMessage } from '@gracile-labs/better-errors/dev/logger'; +import { collectErrorMetadata } from '@gracile-labs/better-errors/dev/utils'; +import { getViteErrorPayload } from '@gracile-labs/better-errors/dev/vite'; +import type { ErrorPayload, ViteDevServer } from 'vite'; + +import { renderLitTemplate } from '../render/utils.js'; +import { GRACILE_JS_ERRORS_DOCS_BASE, GracileErrorData } from './errors.js'; +import { builtInErrorPage } from './pages.js'; + +const logger = getLogger(); + +export async function emitViteBetterError({ + error, + vite, +}: { + error: Error; + vite: ViteDevServer; +}) { + const errorWithMetadata = collectErrorMetadata( + error, + pathToFileURL(vite.config.root), + ); + logger.error( + formatErrorMessage( + errorWithMetadata, + false, + GracileErrorData, + GRACILE_JS_ERRORS_DOCS_BASE, + ), + { + timestamp: true, + }, + ); + const payload = await getViteErrorPayload({ + docsBaseUrl: GRACILE_JS_ERRORS_DOCS_BASE, + errorsData: GracileErrorData, + err: errorWithMetadata, + }); + setTimeout(() => { + vite.hot.send(payload as ErrorPayload); + // NOTE: Arbitrary value. Lower seems to be too fast, higher is not guaranteed to work. + }, 200); + const errorPage = builtInErrorPage(error.name ?? 'Error', true); + + const renderedErrorPage = await renderLitTemplate(errorPage); + return renderedErrorPage; +} diff --git a/packages/engine/src/errors/errors-data.ts b/packages/engine/src/errors/errors-data.ts new file mode 100644 index 0000000..db9678d --- /dev/null +++ b/packages/engine/src/errors/errors-data.ts @@ -0,0 +1,113 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export interface ErrorData { + name: string; + title: string; + message?: string | ((...params: any) => string) | undefined; + hint?: string | ((...params: any) => string) | undefined; +} + +/** + * @docs + * @kind heading + * @name Gracile Errors + */ +// Gracile Errors, most errors will go here! + +/** + * + */ +export const FailedToLoadModuleSSR = { + name: 'FailedToLoadModuleSSR', + title: 'Could not import file.', + message: (importName: string) => `Could not import \`${importName}\`.`, + hint: 'This is often caused by a typo in the import path. Please make sure the file exists.', +} as const satisfies ErrorData; +/** + * + */ +// export const FailedToGlobalLogger = { +// name: 'FailedToGlobalLogger', +// title: 'Could not get the global logger.', +// message: undefined, +// hint: 'This a Gracile internal fault.', +// }; +// /** +// * +// */ +// export const FailedToGetGracileVersion = { +// name: 'FailedToGetGracileVersion', +// title: 'Version must be set before.', +// message: undefined, +// hint: 'This a Gracile internal fault.', +// } as const satisfies ErrorData; +/** + * + */ +export const InvalidRequestInAdapter = { + name: 'InvalidRequestInAdapter', + title: 'Invalid request in adapter.', + message: (adapterName: string) => + `Invalid request for adapter name: \`${adapterName}\`.`, + hint: 'Check that you have configured the adapter correctly.', +} as const satisfies ErrorData; +/** + * + */ +export const InvalidResponseInAdapter = { + name: 'InvalidResponseInAdapter', + title: 'Invalid response in adapter.', + message: (adapterName: string) => + `Invalid response for adapter name: \`${adapterName}\`.`, + hint: undefined, +} as const satisfies ErrorData; +/** + * + */ +export const InvalidRouteDocument = { + name: 'RoutePageRender', + title: 'Invalid route document configuration.', + message: (routePath: string) => + `Route document must be a function for: \`${routePath}\`.`, + hint: undefined, +} as const satisfies ErrorData; +/** + * + */ +export const InvalidRouteDocumentResult = { + name: 'RoutePageRender', + title: 'Incorrect document template result.', + message: (routePath: string) => + `Incorrect document template result for: \`${routePath}\`.`, + hint: undefined, +} as const satisfies ErrorData; +/** + * + */ +export const InvalidRouteExport = { + name: 'InvalidRouteExport', + title: 'Invalid route export.', + message: (routePath: string) => `Incorrect route module: \`${routePath}\`.`, + hint: `Should export a default \`defineRoute\` function.`, +} as const satisfies ErrorData; +/** + * + */ +export const CouldNotRenderRouteDocument = { + name: 'CouldNotRenderRouteDocument', + title: 'Could not render the route document.', + message: (routePath: string) => + `Could not render the route document for: \`${routePath}\`.`, + hint: undefined, +} as const satisfies ErrorData; +/** + * + */ +// export const NoRoutesFound = { +// name: 'NoRoutesFound', +// title: 'Could not find any routes.', +// message: `Could not find any routes for your project.`, +// hint: 'You have to populate the `src/routes` folder.', +// }; + +// `Wrong template result for fragment template ${routeInfos.foundRoute.filePath}.`, diff --git a/packages/engine/src/errors/errors.ts b/packages/engine/src/errors/errors.ts new file mode 100644 index 0000000..8ea0862 --- /dev/null +++ b/packages/engine/src/errors/errors.ts @@ -0,0 +1,159 @@ +/* eslint-disable max-classes-per-file */ +/* eslint-disable @typescript-eslint/lines-between-class-members */ +// NOTE: Taken and adapted from https://github.com/withGracile/Gracile/blob/cf65476b27053333cf5a36f6f9f46b794c98dfa2/packages/Gracile/src/core/errors/errors.ts + +import { + BetterError, + type BuiltinErrorTypes, +} from '@gracile-labs/better-errors/errors'; + +// import { codeFrame } from '@gracile-labs/better-errors/printer'; + +export * as GracileErrorData from './errors-data.js'; + +export const GRACILE_JS_ERRORS_DOCS_BASE = + 'https://gracile.js.org/docs/references/errors'; + +type ErrorTypes = + | BuiltinErrorTypes + | 'GracileError' + | 'TemplateError' + | 'InternalError' + | 'TypeGuardError' + | 'AggregateError'; + +// | 'BetterError' +// | 'GracileUserError' +// | 'CompilerError' +// | 'CSSError' +// | 'MarkdownError' + +export class GracileError extends BetterError { + type: ErrorTypes = 'GracileError'; + + static is(err: unknown): err is GracileError { + return (err as GracileError).type === 'GracileError'; + } +} + +export class TemplateError extends GracileError { + type: ErrorTypes = 'TemplateError'; + + static is(err: unknown): err is TemplateError { + return (err as TemplateError).type === 'TemplateError'; + } +} + +export class InternalError extends GracileError { + type: ErrorTypes = 'InternalError'; + + static is(err: unknown): err is InternalError { + return (err as InternalError).type === 'InternalError'; + } +} + +// +// /* eslint-disable @typescript-eslint/lines-between-class-members */ +// /* eslint-disable max-classes-per-file */ + +// import type { buildErrorMessage } from 'vite'; + +// export type RollupError = Parameters['0']; + +// export abstract class BetterError extends Error implements RollupError { +// // --- RollupError +// watchFiles?: string[]; +// binding?: string; +// cause?: unknown; +// code?: string; +// exporter?: string; +// frame?: string; +// hook?: string; +// id?: string; +// ids?: string[]; +// loc?: { column: number; file?: string; line: number }; +// meta?: unknown; +// names?: string[]; +// plugin?: string; +// pluginCode?: unknown; +// pos?: number; +// reexporter?: string; +// stack?: string; +// url?: string; +// // --- End RollupError + +// constructor(message: string, options?: { cause?: unknown }) { +// super(message); +// this.name = 'BetterError'; +// this.cause = options?.cause; +// } +// } + +// export class GracileUnknownError extends BetterError { +// constructor(message: string, options?: { cause?: unknown }) { +// super(message, options); +// this.name = 'GracileUnknownError'; +// } +// } + +// export class SsrError extends BetterError { +// constructor(message: string, options?: { cause?: unknown }) { +// super(message, options); +// this.name = 'SsrError'; +// } +// } + +// export function isViteError(error: unknown): error is RollupError { +// if ( +// typeof error === 'object' && +// error && +// 'message' in error && +// 'plugin' in error && +// typeof error.plugin === 'string' && +// error.plugin.startsWith(`vite:`) +// ) +// return true; +// return false; +// } + +// // export type EsbuildError = { +// // plugin: 'vite:esbuild'; +// // } & RollupError; + +// // export class GracileRouterError extends Error implements e { +// // constructor(message: string, options: { cause: unknown }) { +// // super(message); +// // this.name = 'GracileRouterError'; +// // this.cause = options.cause; +// // } +// // } + +// /** +// * Special error that is exposed to users. +// * Compared to BetterError, it contains a subset of information. +// */ +// export class GracileUserError extends Error { +// type: ErrorTypes = 'GracileUserError'; +// /** +// * A message that explains to the user how they can fix the error. +// */ +// hint: string | undefined; +// name = 'GracileUserError'; + +// constructor(message: string, hint?: string) { +// super(); +// this.message = message; +// this.hint = hint; +// } + +// static is(err: unknown): err is GracileUserError { +// return (err as GracileUserError).type === 'GracileUserError'; +// } +// } + +// --- + +/** + * An error util. that should never be called in theory. + */ +export class TypeGuardError extends Error {} diff --git a/packages/engine/src/errors/pages.ts b/packages/engine/src/errors/pages.ts new file mode 100644 index 0000000..b2ce3d4 --- /dev/null +++ b/packages/engine/src/errors/pages.ts @@ -0,0 +1,143 @@ +import { html } from '@lit-labs/ssr'; + +export function errorInline(error: Error) { + return html` +
+ SSR Template error! +
+ Stack trace +
${error.stack}
+
+
`; +} + +const minimalStyles = () => + html``; + +const logo = () => + /* prettier-ignore */ + html``; + +export function builtIn404Page(path: string, dev = false) { + return html` + + + + + + + 404: Not found + + ${minimalStyles()} + + + +
+ + +

404: Not found

+ +
+
Path:
+
${path}
+
+ + ${dev + ? html`
+

The page or route may be not configured properly.

+ +

+ See + Defining routes + in the documentation. +

+
` + : null} +
+ + + `; +} + +// TODO: Extract +// const ESCAPE_SEQUENCE_CHARACTER = +// // eslint-disable-next-line no-control-regex +// /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; + +export function builtInErrorPage(name = 'Error', dev = false) { + const title = `500: ${name ? `${name} | ` : ''}Internal Server Error`; + + return html` + + + + + + ${title} + ${minimalStyles()} + ${dev + ? html`` + : null} + + +

${title}

+ + + `; +} diff --git a/packages/engine/src/errors/templates.ts b/packages/engine/src/errors/templates.ts deleted file mode 100644 index 068ee82..0000000 --- a/packages/engine/src/errors/templates.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { html } from '@lit-labs/ssr'; - -export function errorInline(error: Error) { - return html` -
- SSR Template error! -
- Stack trace -
${error.stack}
-
-
`; -} - -export function errorPage(error: Error) { - return html` - - - - - - - - - Error - - - - -
-

šŸ˜µ An error has occurred!

- - -
- -
${error.stack}
- - - -
-
- - - - - `; -} diff --git a/packages/engine/src/logging/messages.ts b/packages/engine/src/logging/messages.ts new file mode 100644 index 0000000..bbd0388 --- /dev/null +++ b/packages/engine/src/logging/messages.ts @@ -0,0 +1,10 @@ +import c from 'picocolors'; + +export function emptyRoutes() { + const message = + c.yellow(`No route were found in the \`src/routes/\` folder.\n\n`) + + c.magenta( + `ā–¶ See https://gracile.js.org/docs/learn/usage/defining-routes/.`, + ); + return `${message}\n`; +} diff --git a/packages/engine/src/plugin.ts b/packages/engine/src/plugin.ts index 39564a9..12de100 100644 --- a/packages/engine/src/plugin.ts +++ b/packages/engine/src/plugin.ts @@ -2,6 +2,7 @@ import { join } from 'node:path'; import { createLogger } from '@gracile/internal-utils/logger/helpers'; import { getVersion } from '@gracile/internal-utils/version'; +import { betterErrors } from '@gracile-labs/better-errors/plugin'; import { rename, rm } from 'fs/promises'; import c from 'picocolors'; import { build, createServer, type PluginOption } from 'vite'; @@ -72,6 +73,9 @@ export const gracile = (config?: GracileConfig): any[] => { }); return [ + betterErrors({ + overlayImportPath: '@gracile/gracile/_internals/vite-custom-overlay', + }), // { // name: 'gracile-routes-codegen', @@ -103,18 +107,22 @@ export const gracile = (config?: GracileConfig): any[] => { // }, // }, - virtualRoutesForClient, - { name: 'vite-plugin-gracile-serve-middleware', apply: 'serve', - config() { + config(_, env) { + if (env.isPreview) return null; return { // NOTE: Supresses message: `Could not auto-determine entry point from rollupOptions or html filesā€¦` + // FIXME: It's not working when reloading the Vite config. + // Is user config, putting `optimizeDeps: { include: [] }` solve this. optimizeDeps: { include: [] }, + // NOTE: Useful? It breaks preview (expected) + appType: 'custom', + // resolve: { // conditions: ['development'], // }, @@ -154,17 +162,30 @@ export const gracile = (config?: GracileConfig): any[] => { timestamp: true, }); + server.watcher.on('ready', () => { + setTimeout(() => { + logger.info(''); + logger.info(c.green('Watching for file changesā€¦'), { + timestamp: true, + }); + logger.info(''); + }, 100); + // s + }); + return () => { server.middlewares.use((req, res, next) => { const locals = config?.dev?.locals?.({ nodeRequest: req }); - Promise.resolve(nodeAdapter(handler)(req, res, locals)).catch( - (error) => next(error), - ); + Promise.resolve( + nodeAdapter(handler, { logger })(req, res, locals), + ).catch((error) => next(error)); }); }; }, }, + virtualRoutesForClient, + { name: 'vite-plugin-gracile-build', @@ -295,6 +316,9 @@ export const gracile = (config?: GracileConfig): any[] => { return ` import { routeAssets, routeImports, routes } from 'gracile:routes'; import { createGracileHandler } from '@gracile/gracile/_internals/server-runtime'; +import { createLogger } from '@gracile/gracile/_internals/logger'; + +createLogger(); export const handler = createGracileHandler({ root: process.cwd(), diff --git a/packages/engine/src/render/route-template.ts b/packages/engine/src/render/route-template.ts index 29fed6b..28b108d 100644 --- a/packages/engine/src/render/route-template.ts +++ b/packages/engine/src/render/route-template.ts @@ -2,11 +2,16 @@ import { Readable } from 'node:stream'; import * as assert from '@gracile/internal-utils/assertions'; import { html } from '@gracile/internal-utils/dummy-literals'; +import type { ErrorLocation } from '@gracile-labs/better-errors/errors'; import { render as renderLitSsr } from '@lit-labs/ssr'; import { collectResult } from '@lit-labs/ssr/lib/render-result.js'; import type { ViteDevServer } from 'vite'; -import { isLitServerTemplate, isLitTemplate } from '../assertions.js'; +import { + GracileError, + GracileErrorData, + TemplateError, +} from '../errors/errors.js'; import type { RouteInfos } from '../routes/match.js'; import type * as R from '../routes/route.js'; import { PAGE_ASSETS_MARKER, SSR_OUTLET_MARKER } from './markers.js'; @@ -44,6 +49,10 @@ export async function renderRouteTemplate({ serverMode?: boolean | undefined; docOnly?: boolean | undefined; }): Promise<{ output: null | Readable; document: null | string }> { + const location = { + file: routeInfos.foundRoute.filePath, + } satisfies ErrorLocation; + if (!routeInfos.routeModule.document && !routeInfos.routeModule.template) return { output: null, document: null }; @@ -59,10 +68,13 @@ export async function renderRouteTemplate({ const fragmentOutput = await Promise.resolve( routeInfos.routeModule.template?.(context) as unknown, ); - if (isLitTemplate(fragmentOutput) === false) - throw Error( - `Wrong template result for fragment template ${routeInfos.foundRoute.filePath}.`, - ); + if (assert.isLitTemplate(fragmentOutput) === false) + throw new GracileError({ + ...GracileErrorData.InvalidRouteDocument, + message: GracileErrorData.InvalidRouteDocument.message(location.file), + // location, + }); + const fragmentRender = renderLitSsr(fragmentOutput); const output = Readable.from(fragmentRender); @@ -74,22 +86,43 @@ export async function renderRouteTemplate({ !routeInfos.routeModule.document || typeof routeInfos.routeModule.document !== 'function' ) - throw new TypeError( - `Route document must be a function ${routeInfos.foundRoute.filePath}.`, - ); + throw new GracileError({ + ...GracileErrorData.InvalidRouteDocument, + message: GracileErrorData.InvalidRouteDocument.message(location.file), + location, + }); const baseDocTemplateResult = await Promise.resolve( routeInfos.routeModule.document?.(context) as unknown, ); - if (isLitServerTemplate(baseDocTemplateResult) === false) - throw new TypeError( - `Incorrect document template result for ${routeInfos.foundRoute.filePath}.`, + if (assert.isLitServerTemplate(baseDocTemplateResult) === false) + throw new GracileError({ + ...GracileErrorData.InvalidRouteDocumentResult, + message: GracileErrorData.InvalidRouteDocumentResult.message( + location.file, + ), + location, + }); + + let baseDocRendered: string; + + // console.log({ ddd: baseDocTemplateResult }); + + try { + baseDocRendered = await collectResult(renderLitSsr(baseDocTemplateResult)); + } catch (e) { + throw new TemplateError( + { + ...GracileErrorData.CouldNotRenderRouteDocument, + message: GracileErrorData.CouldNotRenderRouteDocument.message( + location.file, + ), + location, + }, + { cause: String(e as Error) }, ); - - const baseDocRendered = await collectResult( - renderLitSsr(baseDocTemplateResult), - ); + } // MARK: Sibling assets let baseDocRenderedWithAssets = baseDocRendered; @@ -117,7 +150,8 @@ export async function renderRouteTemplate({ return html` `; } - throw new Error('Unknown asset.'); + // NOTE: Never called (filtered upstream in `collectRoutes`) + return null; }) .join('\n')}` + `\n ` diff --git a/packages/engine/src/routes/collect.ts b/packages/engine/src/routes/collect.ts index 8f4f945..6f7a0ae 100644 --- a/packages/engine/src/routes/collect.ts +++ b/packages/engine/src/routes/collect.ts @@ -7,7 +7,7 @@ import c from 'picocolors'; import { URLPattern } from 'urlpattern-polyfill/urlpattern'; import { createFilter } from 'vite'; -// import type { ViteDevServer } from 'vite'; +import { emptyRoutes } from '../logging/messages.js'; import { prepareSortableRoutes, routeComparator } from './comparator.js'; import { REGEXES } from './load-module.js'; import type * as R from './route.js'; @@ -35,10 +35,6 @@ function extractRoutePatterns( if (pathParts.length === 1 && pathParts.at(0) === 'index') pathParts = []; - // NOTE: Disabled for now, but might be useful later - // if (pathParts.length === 1 && pathParts.at(0) === '404') - // pathParts = ['__404']; - let hasParams = false; const pathRelNorm = pathParts.map((pathEntry) => { @@ -69,6 +65,8 @@ function extractRoutePatterns( }; } +export const WATCHED_FILES_REGEX = + /\/src\/routes\/(.*)\.(ts|js|css|scss|sass|less|styl|stylus)$/; // const routes: R.RoutesManifest = new Map(); export async function collectRoutes( @@ -76,7 +74,7 @@ export async function collectRoutes( root: string /* vite: ViteDevServer */, excludePatterns: string[] = [], // single: { file?: string; event: 'add' }, -): Promise { +): Promise { routes.clear(); const routesFolder = 'src/routes'; @@ -107,6 +105,13 @@ export async function collectRoutes( serverEntrypointsFilter(f), ); + if (serverEntrypoints.length === 0) { + logger.warnOnce(emptyRoutes(), { + timestamp: true, + }); + return; + } + // MARK: Routes priority order // TODO: `prepareSortableRoutes` and `routeComparator` in same function `sortRoutes` const serverEntrypointsSorted = prepareSortableRoutes(serverEntrypoints) @@ -169,6 +174,4 @@ export async function collectRoutes( route.pageAssets.push(assetPathWithExt); }); }); - - return routes; } diff --git a/packages/engine/src/routes/load-module.ts b/packages/engine/src/routes/load-module.ts index bf5829e..2d822fa 100644 --- a/packages/engine/src/routes/load-module.ts +++ b/packages/engine/src/routes/load-module.ts @@ -1,5 +1,10 @@ -import type { ViteDevServer } from 'vite'; +import { collectErrorMetadata } from '@gracile-labs/better-errors/dev/utils'; +import { enhanceViteSSRError } from '@gracile-labs/better-errors/dev/vite'; +import { join } from 'path'; +import { pathToFileURL } from 'url'; +import { type ViteDevServer } from 'vite'; +import { GracileError, GracileErrorData } from '../errors/errors.js'; import * as R from './route.js'; // const ROUTE_SPREAD = /^\.{3}.+$/; @@ -14,6 +19,12 @@ export const REGEXES = { index: /\((.*)\)/, }; +const incorrectRouteModuleError = (p: string) => + new GracileError({ + ...GracileErrorData.InvalidRouteExport, + message: GracileErrorData.InvalidRouteExport.message(p), + }); + export async function loadForeignRouteObject({ vite, route, @@ -27,8 +38,31 @@ export async function loadForeignRouteObject({ let unknownRouteModule: Record | null = null; - if (vite) unknownRouteModule = await vite.ssrLoadModule(route.filePath); - else if (routeImports) { + if (vite) { + try { + unknownRouteModule = await vite.ssrLoadModule( + route.filePath /* + 's' */, + {}, + ); + } catch (e) { + const err = e; + + const filePath = pathToFileURL(join(vite.config.root, route.filePath)); + const rootFolder = pathToFileURL(vite.config.root); + + // NOTE: Maybe it's not required here? But just upstream (safeErrorā€¦) + const enhance = enhanceViteSSRError({ + error: err, + filePath, + // @ts-expect-error Typings mismatches + vite, + }); + + const errorWithMetadata = collectErrorMetadata(enhance, rootFolder); + + throw errorWithMetadata as Error; + } + } else if (routeImports) { const ri = routeImports.get(route.pattern.pathname); if (ri) unknownRouteModule = await Promise.resolve(ri()); @@ -37,16 +71,12 @@ export async function loadForeignRouteObject({ if (unknownRouteModule === null) throw new Error('Cannot find route module.'); const routeModuleFactory = unknownRouteModule['default']; - - const errorBase = `Incorrect route module ${route.filePath}!`; - if (typeof routeModuleFactory !== 'function') - throw new TypeError(`${errorBase} Not a function.`); + throw incorrectRouteModuleError(route.filePath); const routeModule = routeModuleFactory(R.RouteModule) as unknown; - if (routeModule instanceof R.RouteModule === false) - throw new TypeError(`${errorBase} Not a RouteModule.`); + throw incorrectRouteModuleError(route.filePath); return routeModule; } diff --git a/packages/engine/src/routes/match.ts b/packages/engine/src/routes/match.ts index 04f321a..be7dbae 100644 --- a/packages/engine/src/routes/match.ts +++ b/packages/engine/src/routes/match.ts @@ -16,7 +16,7 @@ type MatchedRoute = { function matchRouteFromUrl( url: string, routes: R.RoutesManifest, -): MatchedRoute { +): MatchedRoute | null { let match: URLPatternResult | undefined; let foundRoute: R.Route | undefined; @@ -35,8 +35,7 @@ function matchRouteFromUrl( } } - if (!match || !foundRoute) - throw new Error(`No route matching for ${url}`, { cause: 404 }); + if (!match || !foundRoute) return null; const params: Params = Object.freeze({ ...match.pathname.groups }); @@ -79,10 +78,11 @@ async function extractStaticPaths(options: { }); if (hasCorrectParams === false) - throw new Error( - `Incorrect route parameters for \`${options.pathname}\`.\n` + - `Check \`staticPaths\` for \`${options.foundRoute.filePath}\`.`, - ); + // throw new Error( + // `Incorrect route parameters for \`${options.pathname}\`.\n` + + // `Check \`staticPaths\` for \`${options.foundRoute.filePath}\`.`, + // ); + return null; return { staticPaths, props }; } @@ -99,11 +99,13 @@ export async function getRoute(options: { vite?: ViteDevServer | undefined; routes: R.RoutesManifest; routeImports?: R.RoutesImports | undefined; -}): Promise { - const { foundRoute, pathname, params } = matchRouteFromUrl( - options.url, - options.routes, - ); +}): Promise { + // throw new GracileError(new Error(`No route matching for ${url}`), { + // cause: 404, + // }); + const matchedRoute = matchRouteFromUrl(options.url, options.routes); + if (!matchedRoute) return matchedRoute; + const { foundRoute, pathname, params } = matchedRoute; // TODO: Simplify all the routes things const routeModule = await loadForeignRouteObject({ @@ -112,12 +114,16 @@ export async function getRoute(options: { routeImports: options.routeImports, }); - const staticPaths = await extractStaticPaths({ - routeModule, - foundRoute, - pathname, - params, - }); + let staticPaths: ExtractedStaticPaths | null = null; + if (routeModule.staticPaths) { + staticPaths = await extractStaticPaths({ + routeModule, + foundRoute, + pathname, + params, + }); + if (!staticPaths) return null; + } return { params, diff --git a/packages/engine/src/routes/route.ts b/packages/engine/src/routes/route.ts index 3eb46c5..e094caa 100644 --- a/packages/engine/src/routes/route.ts +++ b/packages/engine/src/routes/route.ts @@ -75,26 +75,26 @@ export class RouteModule { } constructor(options: ModuleOptions) { - if (typeof options.staticPaths === 'function') - this.#staticPaths = options.staticPaths; + // if (typeof options.staticPaths === 'function') + this.#staticPaths = options.staticPaths; - if ( - (typeof options.handler === 'object' || - typeof options.handler === 'function') && - options.handler - ) - this.#handler = options.handler; + // if ( + // (typeof options.handler === 'object' || + // typeof options.handler === 'function') && + // options.handler + // ) + this.#handler = options.handler; this.#locals = {}; - if (typeof options.template === 'function') - this.#template = options.template; + // if (typeof options.template === 'function') + this.#template = options.template; - if (typeof options.document === 'function') - this.#document = options.document; + // if (typeof options.document === 'function') + this.#document = options.document; - if (typeof options.prerender === 'boolean') - this.#prerender = options.prerender; + // if (typeof options.prerender === 'boolean') + this.#prerender = options.prerender; } } diff --git a/packages/engine/src/server/adapters/hono.ts b/packages/engine/src/server/adapters/hono.ts index 5cf92ff..9daea77 100644 --- a/packages/engine/src/server/adapters/hono.ts +++ b/packages/engine/src/server/adapters/hono.ts @@ -3,6 +3,8 @@ import { Readable } from 'node:stream'; import { fileURLToPath } from 'node:url'; import { createLogger } from '@gracile/internal-utils/logger/helpers'; + +import { GracileError, GracileErrorData } from '../../errors/errors.js'; import { constants } from '../constants.js'; import type { AdapterOptions, GracileHandler } from '../request.js'; diff --git a/packages/engine/src/server/adapters/node.ts b/packages/engine/src/server/adapters/node.ts index 098131e..b3c37c3 100644 --- a/packages/engine/src/server/adapters/node.ts +++ b/packages/engine/src/server/adapters/node.ts @@ -6,6 +6,7 @@ import { createLogger } from '@gracile/internal-utils/logger/helpers'; import { createServerAdapter } from '@whatwg-node/server'; import type { IncomingMessage, ServerResponse } from 'http'; +import { GracileError, GracileErrorData } from '../../errors/errors.js'; import { constants } from '../constants.js'; import { type AdapterOptions, @@ -72,18 +73,34 @@ export function nodeAdapter( locals?: unknown, ) { - const request = (await Promise.resolve( - nodeRequestToStandardRequest.handleNodeRequest( - // HACK: Incorrect typings - req as IncomingMessage & { url: string; method: string }, - ), - )) as unknown as Request; + const logger = createLogger(options?.logger); + + let webRequest: Request; + + try { + webRequest = (await Promise.resolve( + nodeRequestToStandardRequest.handleNodeRequest( + // HACK: Exact optional properties + req as IncomingMessage & { url?: string; method?: string }, + ), + )) as unknown as Request; + } catch (e) { + throw new GracileError( + { + ...GracileErrorData.InvalidRequestInAdapter, + message: GracileErrorData.InvalidRequestInAdapter.message('Node'), + }, + { + cause: e, + }, + ); + } const mergedLocals = { ...(locals ?? {}), ...('locals' in res && typeof res.locals === 'object' ? res.locals : {}), }; - const result = await handler(request, mergedLocals); + const result = await handler(webRequest, mergedLocals); if (result?.body) { standardResponseInitToNodeResponse(result.init, res); @@ -110,10 +127,12 @@ export function nodeAdapter( .catch((e) => logger.error(String(e))); return piped; } - return null; } - return null; + throw new GracileError({ + ...GracileErrorData.InvalidResponseInAdapter, + message: GracileErrorData.InvalidResponseInAdapter.message('Node'), + }); }; } diff --git a/packages/engine/src/server/request.ts b/packages/engine/src/server/request.ts index 3bcf416..3daef76 100644 --- a/packages/engine/src/server/request.ts +++ b/packages/engine/src/server/request.ts @@ -2,11 +2,14 @@ import { Readable } from 'node:stream'; import * as assert from '@gracile/internal-utils/assertions'; import { getLogger } from '@gracile/internal-utils/logger/helpers'; +// import { createSafeError } from '@gracile-labs/better-errors/dev/utils'; +import type { BetterErrorPayload } from '@gracile-labs/better-errors/dev/vite'; import c from 'picocolors'; -import type { ErrorPayload, ViteDevServer } from 'vite'; +import type { Logger, ViteDevServer } from 'vite'; -import * as assert from '../assertions.js'; -import { errorPage } from '../errors/templates.js'; +import type { emitViteBetterError as emitViteBe } from '../errors/create-vite-better-error.js'; +import { GracileError } from '../errors/errors.js'; +import { builtIn404Page, builtInErrorPage } from '../errors/pages.js'; import { renderRouteTemplate } from '../render/route-template.js'; import { renderLitTemplate } from '../render/utils.js'; import { getRoute } from '../routes/match.js'; @@ -65,9 +68,15 @@ export function createGracileHandler({ const logger = getLogger(); const middleware: GracileHandler = async (request, locals) => { - try { - const { url: requestedUrl, method } = request; + const { url: requestedUrl, method } = request; + + let emitViteBetterError: typeof emitViteBe | null = null; + if (vite) + emitViteBetterError = await import( + '../errors/create-vite-better-error.js' + ).then(({ emitViteBetterError: e }) => e); + try { // MARK: Rewrite hidden route siblings const fullUrl = requestedUrl.replace(/\/__(.*)$/, '/'); @@ -87,28 +96,25 @@ export function createGracileHandler({ routeImports, }; - const routeInfos = await getRoute(routeOptions).catch(async (error) => { - // MARK: User defined Gracile 404 rewriting - logger.error(String(error)); - const url = new URL('/404/', fullUrl).href; - const options = { ...routeOptions, url }; - const notFound = await getRoute(options).catch((err) => err as Error); - return notFound; - }); + const responseInit: ResponseInit = {}; - if (routeInfos instanceof Error) { - // MARK: Default, fallback 404 - // const message = `404 not found!\n\n---\n\nCreate a /src/routes/404.{js,ts} to get a custom page.\n${method} - ${fullUrl}`; + let routeInfos = await getRoute(routeOptions); - const { errorPageHtml, headers } = await createErrorPage( - fullUrl, - routeInfos, - ); + // MARK: 404 + if (routeInfos === null) { + responseInit.status = 404; + + const url = new URL('/404/', fullUrl).href; + const options = { ...routeOptions, url }; + const notFound = await getRoute(options); + routeInfos = notFound; + } + // MARK: fallback 404 + if (routeInfos === null) { + const page = builtIn404Page(new URL(fullUrl).pathname, Boolean(vite)); return { - response: new Response(errorPageHtml, { - headers, - status: 404, - statusText: '404 not found!', + response: new Response(await renderLitTemplate(page), { + headers: { ...CONTENT_TYPE_HTML }, }), }; } @@ -137,8 +143,6 @@ export function createGracileHandler({ const handler = routeInfos.routeModule.handler; - const responseInit: ResponseInit = {}; - if ( ('handler' in routeInfos.routeModule && typeof handler !== 'undefined') || @@ -269,29 +273,34 @@ export function createGracileHandler({ }; return { body: output.on('error', (error) => { - // NOTE: I think it's not usable here - // if (vite) vite.ssrFixStacktrace(error); - const errorMessage = `[SSR Error] There was an error while rendering a template chunk on server-side.\n` + `It was omitted from the resulting HTML.\n`; if (vite) { logger.error(errorMessage + error.stack); + + // emitViteBetterError(new GracileError(GracileErrorData.FailedToGlobalLogger), vite); const payload = { type: 'error', - err: { + // FIXME: Use the emitViteBetterError instead (but flaky for now with streaming) + // @ts-expect-error ........... + err: new GracileError({ + name: 'StreamingError', + title: 'An error occured during the page template streaming.', message: errorMessage, - stack: error.stack ?? '', - plugin: 'gracile', - - // NOTE: Other options seems to be unused by the overlay - }, - } satisfies ErrorPayload; + hint: 'This is often caused by a wrong template location dynamic interpolation.', + // @ts-expect-error ........... + cause: error, + // highlightedCode: error.message, + }), + } satisfies BetterErrorPayload; + // setTimeout(() => { + // @ts-expect-error ........... vite.hot.send(payload); // NOTE: Arbitrary value. Lower seems to be too fast, higher is not guaranteed to work. - }, 750); + }, 200); } else { logger.error(errorMessage); } @@ -304,17 +313,17 @@ export function createGracileHandler({ return null; // MARK: Errors - } catch (e) { - const error = e as Error; - - if (vite) vite.ssrFixStacktrace(error); - - const { errorPageHtml: ultimateErrorPage, headers } = - await createErrorPage('__gracile_error', error); + } catch (error) { + // const safeError = createSafeError(error); + // TODO: User defined dev/runtime 500 error + const ultimateErrorPage = + vite && emitViteBetterError + ? await emitViteBetterError({ vite, error: error as Error }) + : await renderLitTemplate(builtInErrorPage((error as Error).name)); return { - response: new Response(String(ultimateErrorPage), { - headers, + response: new Response(ultimateErrorPage, { + headers: { ...CONTENT_TYPE_HTML }, status: 500, statusText: 'Gracile middleware error', }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a8a388..fb4d181 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -357,8 +357,8 @@ importers: specifier: ^12.0.0 version: 12.0.0 picocolors: - specifier: ^1.0.0 - version: 1.0.0 + specifier: ^1.0.1 + version: 1.0.1 devDependencies: '@gracile/internal-tsconfigs': specifier: workspace:^ @@ -3933,9 +3933,6 @@ packages: resolution: {integrity: sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==} engines: {node: '>=12'} - picocolors@1.0.0: - resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} - picocolors@1.0.1: resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} @@ -8520,8 +8517,6 @@ snapshots: path-type@5.0.0: {} - picocolors@1.0.0: {} - picocolors@1.0.1: {} picomatch@2.3.1: {}