diff --git a/packages/core/src/dev/index.ts b/packages/core/src/dev/index.ts index 9699ff8..71d327f 100644 --- a/packages/core/src/dev/index.ts +++ b/packages/core/src/dev/index.ts @@ -1,5 +1,33 @@ -import type { MightyDevMiddleware, MightyServerOptions } from "@/types"; -import { setupDev } from "./setup"; +import { access } from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { + type AstroConfig, + type AstroInlineConfig, + dev as astroDev, +} from "astro"; +import { mergeConfig } from "astro/config"; +import type { Element } from "hast"; +import type { ViteDevServer } from "vite"; +import { getStylesForURL } from "@/dev/css"; +import type { + MightyDevMiddleware, + MightyRenderDevRequest, + MightyServerOptions, +} from "@/types"; +import { dotStringToPath } from "@/utils/dotStringToPath"; +import { injectTagsIntoHead } from "@/utils/injectTagsIntoHead"; +import { MIGHTY_DEV_PLACEHOLDER_ADDRESS } from "./constants"; +import type { + MightyRenderFunction, + MightyStartContainerFunction, +} from "./render-vite"; +import { loadRenderersFromIntegrations } from "./renderers"; +import { createResolve } from "./resolve"; +import { getInjectedScriptsFromIntegrations } from "./scripts"; + +const require = createRequire(import.meta.url); +const devDir = path.join(path.dirname(require.resolve("@gomighty/core/dev"))); /** * Starts the Mighty development server. @@ -7,5 +35,207 @@ import { setupDev } from "./setup"; export async function dev( options: MightyServerOptions, ): Promise { - return setupDev(options); + let finalConfig: AstroConfig; + let viteServer: ViteDevServer; + + const mightyConfig: AstroInlineConfig = { + vite: { + server: { + middlewareMode: true, + cors: false, + }, + plugins: [ + { + name: "mighty-remove-unhandled-rejection-listener-hack", + closeBundle() { + // HACK: We remove the "unhandledRejection" event listener added by Astro + // https://github.com/withastro/astro/blob/eadc9dd277d0075d7bff0e33c7a86f3fb97fdd61/packages/astro/src/vite-plugin-astro-server/plugin.ts#L125 + // The original removal logic assumes a Vite server is running, which is not the case in middleware mode + process.removeAllListeners("unhandledRejection"); + }, + }, + ], + }, + integrations: [ + { + name: "mighty-integration", + hooks: { + "astro:config:done": ({ config }) => { + finalConfig = config; + }, + "astro:server:setup": async ({ server }) => { + server.listen = async () => { + return server; + }; + // @ts-expect-error - This is a hack to make Astro work in middleware mode + server.httpServer = { + address() { + return null; + }, + }; + server.bindCLIShortcuts = () => {}; + + viteServer = server; + }, + }, + }, + ], + }; + + await astroDev(mergeConfig(mightyConfig, options.config ?? {})); + + // @ts-expect-error - finalConfig is defined at this point + if (!finalConfig) { + throw new Error("finalConfig is not defined"); + } + + // @ts-expect-error viteServer is defined at this point + if (!viteServer) { + throw new Error("viteServer is not defined"); + } + + // We need to import the renderers here and not in the render-vite.ts file. Not sure why... + const loadedRenderers = await loadRenderersFromIntegrations( + finalConfig.integrations, + viteServer, + ); + + const { render: renderComponent, createContainer } = + (await viteServer.ssrLoadModule(path.join(devDir, "./render-vite.ts"))) as { + render: MightyRenderFunction; + createContainer: MightyStartContainerFunction; + }; + + const resolve = createResolve(viteServer.environments.ssr, finalConfig.root); + + await createContainer(loadedRenderers, resolve); + + const injectedScripts = await getInjectedScriptsFromIntegrations( + finalConfig.integrations, + ); + + const headInlineScriptTags: Element[] = injectedScripts + .filter((script) => script.stage === "head-inline") + .map((script) => ({ + type: "element", + tagName: "script", + properties: {}, + children: [{ type: "text", value: script.content }], + })); + + const getPageScripts: () => Element[] = injectedScripts.some( + (script) => script.stage === "page", + ) + ? () => [ + { + type: "element", + tagName: "script", + properties: { + type: "module", + src: `${MIGHTY_DEV_PLACEHOLDER_ADDRESS}/@id/astro:scripts/page.js`, + }, + children: [], + }, + ] + : () => []; + + return { + viteMiddleware: viteServer.middlewares, + stop: () => viteServer.close(), + render: async (request: MightyRenderDevRequest) => { + try { + const { + component, + props = {}, + context = {}, + partial = true, + address, + } = request; + + const componentPath: `${string}.astro` = `${path.join( + finalConfig.srcDir.pathname, + "pages", + ...dotStringToPath(component), + )}.astro`; + + const doesComponentExist = await access(componentPath) + .then(() => true) + .catch(() => false); + if (!doesComponentExist) { + return { status: 404, content: `Component ${component} not found` }; + } + + const [rawRenderedComponent, styleTags] = await Promise.all([ + renderComponent({ + componentPath, + props, + context, + partial, + }), + getStylesForURL(componentPath, viteServer).then((styles): Element[] => + styles.styles.map((style) => ({ + type: "element", + tagName: "style", + properties: { + type: "text/css", + "data-vite-dev-id": style.id, + }, + children: [{ type: "text", value: style.content }], + })), + ), + ]); + + // Rewrite image URLs to include the dev address + const renderedComponent = rawRenderedComponent.replace( + /(["'(])\/@fs\//g, + `$1${MIGHTY_DEV_PLACEHOLDER_ADDRESS}/@fs/`, + ); + + const viteClientScript: Element = { + type: "element", + tagName: "script", + properties: { + type: "module", + src: `${MIGHTY_DEV_PLACEHOLDER_ADDRESS}/@vite/client`, + }, + children: [], + }; + + const content = injectTagsIntoHead( + renderedComponent, + [ + ...styleTags, + viteClientScript, + ...getPageScripts(), + ...headInlineScriptTags, + ], + partial, + ).replaceAll(MIGHTY_DEV_PLACEHOLDER_ADDRESS, address); + + return { status: 200, content }; + } catch (error) { + viteServer.ssrFixStacktrace(error as Error); + + const hmr = finalConfig.vite?.server?.hmr; + const overlayEnabled = + typeof hmr === "object" ? hmr.overlay !== false : hmr !== false; + + if (!overlayEnabled) { + throw error; + } + + setTimeout(() => { + viteServer.environments.client.hot.send({ + type: "error", + err: error as Error & { stack: string }, + }); + }, 200); + + return { + status: 500, + content: `${(error as Error).name}`, + }; + } + }, + }; } diff --git a/packages/core/src/dev/setup.ts b/packages/core/src/dev/setup.ts deleted file mode 100644 index 6aadfbb..0000000 --- a/packages/core/src/dev/setup.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { access } from "node:fs/promises"; -import { createRequire } from "node:module"; -import path from "node:path"; -import { - type AstroConfig, - type AstroInlineConfig, - dev as astroDev, -} from "astro"; -import { mergeConfig } from "astro/config"; -import type { Element } from "hast"; -import type { ViteDevServer } from "vite"; -import { getStylesForURL } from "@/dev/css"; -import type { - MightyDevMiddleware, - MightyRenderDevRequest, - MightyServerOptions, -} from "@/types"; -import { dotStringToPath } from "@/utils/dotStringToPath"; -import { injectTagsIntoHead } from "@/utils/injectTagsIntoHead"; -import { MIGHTY_DEV_PLACEHOLDER_ADDRESS } from "./constants"; -import type { - MightyRenderFunction, - MightyStartContainerFunction, -} from "./render-vite"; -import { loadRenderersFromIntegrations } from "./renderers"; -import { createResolve } from "./resolve"; -import { getInjectedScriptsFromIntegrations } from "./scripts"; - -const require = createRequire(import.meta.url); -const devDir = path.join(path.dirname(require.resolve("@gomighty/core/dev"))); - -export async function setupDev( - options: MightyServerOptions, -): Promise { - let finalConfig: AstroConfig; - let viteServer: ViteDevServer; - - const mightyConfig: AstroInlineConfig = { - vite: { - server: { - middlewareMode: true, - cors: false, - }, - plugins: [ - { - name: "mighty-remove-unhandled-rejection-listener-hack", - closeBundle() { - // HACK: We remove the "unhandledRejection" event listener added by Astro - // https://github.com/withastro/astro/blob/eadc9dd277d0075d7bff0e33c7a86f3fb97fdd61/packages/astro/src/vite-plugin-astro-server/plugin.ts#L125 - // The original removal logic assumes a Vite server is running, which is not the case in middleware mode - process.removeAllListeners("unhandledRejection"); - }, - }, - ], - }, - integrations: [ - { - name: "mighty-integration", - hooks: { - "astro:config:done": ({ config }) => { - finalConfig = config; - }, - "astro:server:setup": async ({ server }) => { - server.listen = async () => { - return server; - }; - // @ts-expect-error - This is a hack to make Astro work in middleware mode - server.httpServer = { - address() { - return null; - }, - }; - server.bindCLIShortcuts = () => {}; - - viteServer = server; - }, - }, - }, - ], - }; - - await astroDev(mergeConfig(mightyConfig, options.config ?? {})); - - // @ts-expect-error - finalConfig is defined at this point - if (!finalConfig) { - throw new Error("finalConfig is not defined"); - } - - // @ts-expect-error viteServer is defined at this point - if (!viteServer) { - throw new Error("viteServer is not defined"); - } - - // We need to import the renderers here and not in the render-vite.ts file. Not sure why... - const loadedRenderers = await loadRenderersFromIntegrations( - finalConfig.integrations, - viteServer, - ); - - const { render: renderComponent, createContainer } = - (await viteServer.ssrLoadModule(path.join(devDir, "./render-vite.ts"))) as { - render: MightyRenderFunction; - createContainer: MightyStartContainerFunction; - }; - - const resolve = createResolve(viteServer.environments.ssr, finalConfig.root); - - await createContainer(loadedRenderers, resolve); - - const injectedScripts = await getInjectedScriptsFromIntegrations( - finalConfig.integrations, - ); - - const headInlineScriptTags: Element[] = injectedScripts - .filter((script) => script.stage === "head-inline") - .map((script) => ({ - type: "element", - tagName: "script", - properties: {}, - children: [{ type: "text", value: script.content }], - })); - - const getPageScripts: () => Element[] = injectedScripts.some( - (script) => script.stage === "page", - ) - ? () => [ - { - type: "element", - tagName: "script", - properties: { - type: "module", - src: `${MIGHTY_DEV_PLACEHOLDER_ADDRESS}/@id/astro:scripts/page.js`, - }, - children: [], - }, - ] - : () => []; - - return { - viteMiddleware: viteServer.middlewares, - stop: () => viteServer.close(), - render: async (request: MightyRenderDevRequest) => { - try { - const { - component, - props = {}, - context = {}, - partial = true, - address, - } = request; - - const componentPath: `${string}.astro` = `${path.join( - finalConfig.srcDir.pathname, - "pages", - ...dotStringToPath(component), - )}.astro`; - - const doesComponentExist = await access(componentPath) - .then(() => true) - .catch(() => false); - if (!doesComponentExist) { - return { status: 404, content: `Component ${component} not found` }; - } - - const [rawRenderedComponent, styleTags] = await Promise.all([ - renderComponent({ - componentPath, - props, - context, - partial, - }), - getStylesForURL(componentPath, viteServer).then((styles): Element[] => - styles.styles.map((style) => ({ - type: "element", - tagName: "style", - properties: { - type: "text/css", - "data-vite-dev-id": style.id, - }, - children: [{ type: "text", value: style.content }], - })), - ), - ]); - - // Rewrite image URLs to include the dev address - const renderedComponent = rawRenderedComponent.replace( - /(["'(])\/@fs\//g, - `$1${MIGHTY_DEV_PLACEHOLDER_ADDRESS}/@fs/`, - ); - - const viteClientScript: Element = { - type: "element", - tagName: "script", - properties: { - type: "module", - src: `${MIGHTY_DEV_PLACEHOLDER_ADDRESS}/@vite/client`, - }, - children: [], - }; - - const content = injectTagsIntoHead( - renderedComponent, - [ - ...styleTags, - viteClientScript, - ...getPageScripts(), - ...headInlineScriptTags, - ], - partial, - ).replaceAll(MIGHTY_DEV_PLACEHOLDER_ADDRESS, address); - - return { status: 200, content }; - } catch (error) { - viteServer.ssrFixStacktrace(error as Error); - - const hmr = finalConfig.vite?.server?.hmr; - const overlayEnabled = - typeof hmr === "object" ? hmr.overlay !== false : hmr !== false; - - if (!overlayEnabled) { - throw error; - } - - setTimeout(() => { - viteServer.environments.client.hot.send({ - type: "error", - err: error as Error & { stack: string }, - }); - }, 200); - - return { - status: 500, - content: `${(error as Error).name}`, - }; - } - }, - }; -} diff --git a/packages/core/src/start/index.ts b/packages/core/src/start/index.ts index 3faa1dc..fd1ae23 100644 --- a/packages/core/src/start/index.ts +++ b/packages/core/src/start/index.ts @@ -1,5 +1,18 @@ -import type { MightyStartMiddleware, MightyStartOptions } from "@/types"; -import { setupStart } from "./setup"; +import path from "node:path"; +import { experimental_AstroContainer as AstroContainer } from "astro/container"; +import type { AstroComponentFactory } from "astro/runtime/server/index.js"; +import type { Element } from "hast"; +import { runInContext } from "@/context"; +import type { + MightyRenderRequest, + MightyServerOptions, + MightyStartMiddleware, + MightyStartOptions, +} from "@/types"; +import { dotStringToPath } from "@/utils/dotStringToPath"; +import { getBuildPathsFromInlineConfig } from "@/utils/getBuildPathsFromInlineConfig"; +import { injectTagsIntoHead } from "@/utils/injectTagsIntoHead"; +import { importManifestAndRenderers } from "./importManifestAndRenderers"; /** * Returns a render function that can be used to render Astro components in production. @@ -9,5 +22,96 @@ import { setupStart } from "./setup"; export async function start( options?: MightyStartOptions, ): Promise { - return setupStart(options ?? {}); + const resolvedOptions: MightyServerOptions = options ?? {}; + + const { buildServerPath } = getBuildPathsFromInlineConfig( + resolvedOptions.config ?? {}, + ); + + const { manifest, renderers } = await importManifestAndRenderers( + resolvedOptions.config ?? {}, + ); + + const container = await AstroContainer.create({ + manifest, + renderers, + async resolve(s) { + return manifest.entryModules[s] ?? s; + }, + }); + + return { + render: async (request: MightyRenderRequest) => { + const { component, props = {}, context = {}, partial = true } = request; + + const componentPath = path.join( + "src", + "pages", + ...dotStringToPath(component), + ); + + const routeInfo = manifest.routes.find( + (route) => route.routeData.component === `${componentPath}.astro`, + ); + if (!routeInfo) { + return { status: 404, content: `Component ${component} not found` }; + } + + if (routeInfo.routeData.prerender) { + return { + redirectTo: path.join( + routeInfo.routeData.pathname ?? "/", + "index.html", + ), + }; + } + + const entryModule = + manifest.entryModules[ + `\u0000virtual:astro:page:${componentPath}@_@astro` + ]; + if (!entryModule) { + return { status: 404, content: `Component ${component} not found` }; + } + + const styleTags: Element[] = routeInfo.styles.map((style) => { + if (style.type === "inline") { + return { + type: "element", + tagName: "style", + properties: { + type: "text/css", + }, + children: [{ type: "text", value: style.content }], + }; + } + if (style.type === "external") { + return { + type: "element", + tagName: "link", + properties: { + rel: "stylesheet", + href: style.src, + }, + children: [], + }; + } + throw new Error( + `Unknown style type in manifest: ${JSON.stringify(style)}`, + ); + }); + + const componentModule: AstroComponentFactory = await import( + path.join(buildServerPath, entryModule) + ).then((module) => module.page().default); + const renderedComponent = await runInContext(context, () => + container.renderToString(componentModule, { props, partial }), + ); + + return { + status: 200, + content: injectTagsIntoHead(renderedComponent, styleTags, partial), + }; + }, + }; } diff --git a/packages/core/src/start/setup.ts b/packages/core/src/start/setup.ts deleted file mode 100644 index 7bd5209..0000000 --- a/packages/core/src/start/setup.ts +++ /dev/null @@ -1,109 +0,0 @@ -import path from "node:path"; -import { experimental_AstroContainer as AstroContainer } from "astro/container"; -import type { AstroComponentFactory } from "astro/runtime/server/index.js"; -import type { Element } from "hast"; -import { runInContext } from "@/context"; -import type { - MightyRenderRequest, - MightyServerOptions, - MightyStartMiddleware, -} from "@/types"; -import { dotStringToPath } from "@/utils/dotStringToPath"; -import { getBuildPathsFromInlineConfig } from "@/utils/getBuildPathsFromInlineConfig"; -import { injectTagsIntoHead } from "@/utils/injectTagsIntoHead"; -import { importManifestAndRenderers } from "./importManifestAndRenderers"; - -export async function setupStart( - options: MightyServerOptions, -): Promise { - const { buildServerPath } = getBuildPathsFromInlineConfig( - options.config ?? {}, - ); - - const { manifest, renderers } = await importManifestAndRenderers( - options.config ?? {}, - ); - - const container = await AstroContainer.create({ - manifest, - renderers, - async resolve(s) { - return manifest.entryModules[s] ?? s; - }, - }); - - return { - render: async (request: MightyRenderRequest) => { - const { component, props = {}, context = {}, partial = true } = request; - - const componentPath = path.join( - "src", - "pages", - ...dotStringToPath(component), - ); - - const routeInfo = manifest.routes.find( - (route) => route.routeData.component === `${componentPath}.astro`, - ); - if (!routeInfo) { - return { status: 404, content: `Component ${component} not found` }; - } - - if (routeInfo.routeData.prerender) { - return { - redirectTo: path.join( - routeInfo.routeData.pathname ?? "/", - "index.html", - ), - }; - } - - const entryModule = - manifest.entryModules[ - `\u0000virtual:astro:page:${componentPath}@_@astro` - ]; - if (!entryModule) { - return { status: 404, content: `Component ${component} not found` }; - } - - const styleTags: Element[] = routeInfo.styles.map((style) => { - if (style.type === "inline") { - return { - type: "element", - tagName: "style", - properties: { - type: "text/css", - }, - children: [{ type: "text", value: style.content }], - }; - } - if (style.type === "external") { - return { - type: "element", - tagName: "link", - properties: { - rel: "stylesheet", - href: style.src, - }, - children: [], - }; - } - throw new Error( - `Unknown style type in manifest: ${JSON.stringify(style)}`, - ); - }); - - const componentModule: AstroComponentFactory = await import( - path.join(buildServerPath, entryModule) - ).then((module) => module.page().default); - const renderedComponent = await runInContext(context, () => - container.renderToString(componentModule, { props, partial }), - ); - - return { - status: 200, - content: injectTagsIntoHead(renderedComponent, styleTags, partial), - }; - }, - }; -} diff --git a/packages/core/tests/fixture.ts b/packages/core/tests/fixture.ts index 482cd14..ab361a3 100644 --- a/packages/core/tests/fixture.ts +++ b/packages/core/tests/fixture.ts @@ -4,17 +4,15 @@ import type { AstroInlineConfig } from "astro"; import { mergeConfig } from "astro/config"; import { toFetchResponse, toReqRes } from "fetch-to-node"; import { build } from "@/build"; -import { setupDev } from "@/dev/setup"; -import { setupStart } from "@/start/setup"; +import { dev } from "@/dev"; +import { start } from "@/start"; import type { MightyRenderRequest, MightyServerOptions } from "@/types"; import { dotStringToPath } from "@/utils/dotStringToPath"; export type DevRenderFunction = ( req: MightyRenderRequest, ) => Promise<{ status: number; content: string }>; -export type StartRenderFunction = Awaited< - ReturnType ->["render"]; +export type StartRenderFunction = Awaited>["render"]; export type GetFromViteMiddlewareFunction = ( path: string, @@ -72,7 +70,7 @@ export function getFixture(fixtureName: string): { render: rawRender, stop: stopDevServer, viteMiddleware, - } = await setupDev({ + } = await dev({ ...params, config: mergeConfig( { @@ -125,7 +123,7 @@ export function getFixture(fixtureName: string): { }); }, startProdServer: async (params?: MightyServerOptions) => { - const { render } = await setupStart({ + const { render } = await start({ config: mergeConfig( { root: fixtureRoot,