Skip to content

Commit

Permalink
Add React static adapter (#1328)
Browse files Browse the repository at this point in the history
  • Loading branch information
AlecAivazis authored Jul 25, 2024
1 parent e57e9f6 commit 9090197
Show file tree
Hide file tree
Showing 34 changed files with 17,519 additions and 12,367 deletions.
5 changes: 5 additions & 0 deletions .changeset/beige-pumas-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'houdini': patch
---

Add necessary infrastructure to support spa adapter
5 changes: 5 additions & 0 deletions .changeset/silly-coats-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'houdini-adapter-static': patch
---

initial release
1 change: 1 addition & 0 deletions e2e/kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
},
"dependencies": {
"@sveltejs/adapter-node": "^5.0.1",
"@sveltejs/adapter-static": "^3.0.2",
"graphql-ws": "^5.8.2"
}
}
2 changes: 1 addition & 1 deletion e2e/kit/vite.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
import houdini from 'houdini/vite';
import { libReporter } from 'vite-plugin-lib-reporter';
import adapter from '@sveltejs/adapter-node';
import adapter from '@sveltejs/adapter-static';

/** @type {import('vite').UserConfig} */
const config = {
Expand Down
1 change: 0 additions & 1 deletion e2e/react/houdini.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/// <references types="houdini-react">
/// <references types="houdini-router">
/** @type {import('houdini').ConfigFile} */
const config = {
defaultPartial: true,
Expand Down
3 changes: 2 additions & 1 deletion e2e/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
"e2e-api": "workspace:^",
"graphql-yoga": "^4.0.4",
"houdini": "workspace:^",
"houdini-adapter-cloudflare": "workspace:^",
"houdini-react": "workspace:^",
"react": "19.0.0-rc-eb259b5d3b-20240605",
"react-dom": "19.0.0-rc-eb259b5d3b-20240605",
Expand All @@ -45,6 +44,8 @@
"e2e-api": "workspace:^",
"hono": "^3.6.0",
"houdini-adapter-node": "workspace:^",
"houdini-adapter-cloudflare": "workspace:^",
"houdini-adapter-static": "workspace:^",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.3",
"typescript": "^4.9.3",
Expand Down
2 changes: 1 addition & 1 deletion e2e/react/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import react from '@vitejs/plugin-react'
import adapter from 'houdini-adapter-cloudflare'
import adapter from 'houdini-adapter-node'
import houdini from 'houdini/vite'
import { defineConfig } from 'vite'

Expand Down
2 changes: 0 additions & 2 deletions houdini.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { HoudiniClient } from './packages/houdini/src/runtime/client'

declare namespace App {
interface Session {}
interface Metadata {}
Expand Down
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,15 @@
"@theguild/eslint-config": "^0.8.0",
"@trivago/prettier-plugin-sort-imports": "^4.0.0",
"@types/react": "^18.2.22",
"@vitest/coverage-c8": "^0.28.3",
"@vitest/ui": "^0.28.3",
"@vitest/ui": "^1.6.0",
"eslint-plugin-unused-imports": "^2.0.0",
"graphql": "^15.8.0",
"lint-staged": "^12.3.4",
"prettier": "^2.8.3",
"turbo": "^1.8.8",
"typescript": "^4.9",
"vite": "^4.1.4",
"vitest": "^0.28.3"
"vitest": "^1.6.0"
},
"dependencies": {
"fs-extra": "^10.1.0",
Expand Down
79 changes: 76 additions & 3 deletions packages/adapter-node/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { createServerAdapter } from 'houdini/adapter'
import { createServer } from 'node:http'
import * as fs from 'node:fs'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import path, { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

// create the production server adapter
const serverAdapter = createServerAdapter({
Expand All @@ -8,7 +14,74 @@ const serverAdapter = createServerAdapter({
})

// wrap the server adapter in a node http server
const nodeServer = createServer(serverAdapter)
const nodeServer = createServer((req, res) => {
if (req.url && req.url.startsWith('/assets')) {
return handleAssets(req, res)
}

// if we got this far we can pass the request onto the server adapter
serverAdapter(req, res)
})

const port = process.env.PORT ?? 3000

// start listening on the designated port
nodeServer.listen(process.env.PORT ?? 3000)
nodeServer.listen(port, () => {
console.log(`Server is listening on port ${port} 🚀`)
})

function handleAssets(
req: IncomingMessage,
res: ServerResponse<IncomingMessage> & {
req: IncomingMessage
}
) {
let filePath = path.join(__dirname, req.url === '/' ? 'index.html' : req.url ?? '/')
let extname = path.extname(filePath)
let contentType = 'text/html'

// Determine the content type based on the file extension
switch (extname) {
case '.js':
contentType = 'application/javascript'
break
case '.css':
contentType = 'text/css'
break
case '.json':
contentType = 'application/json'
break
case '.png':
contentType = 'image/png'
break
case '.jpg':
contentType = 'image/jpg'
break
case '.ico':
contentType = 'image/x-icon'
break
default:
contentType = 'text/html'
}

// Read the file from the file system
fs.readFile(filePath, (error, content) => {
if (error) {
if (error.code === 'ENOENT') {
// If the file is not found, return a 404
fs.readFile(path.join(__dirname, '404.html'), (error, content) => {
res.writeHead(404, { 'Content-Type': 'text/html' })
res.end(content, 'utf8')
})
} else {
// For any other errors, return a 500
res.writeHead(500)
res.end(`Server Error: ${error.code}`)
}
} else {
// If the file is found, serve it
res.writeHead(200, { 'Content-Type': contentType })
res.end(content, 'utf8')
}
})
}
6 changes: 3 additions & 3 deletions packages/adapter-node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import { fileURLToPath } from 'node:url'

const adapter: Adapter = async ({ outDir, adapterPath }) => {
// read the contents of the app file
let workerContents = (await fs.readFile(
let serverContents = (await fs.readFile(
fileURLToPath(new URL('./app.js', import.meta.url).href)
))!

// make sure that the adapter module imports from the correct path
workerContents = workerContents.replaceAll('houdini/adapter', adapterPath)
serverContents = serverContents.replaceAll('houdini/adapter', adapterPath + '.js')

await fs.writeFile(path.join(outDir, 'index.js'), workerContents!)
await fs.writeFile(path.join(outDir, 'index.js'), serverContents!)
}

export default adapter
1 change: 1 addition & 0 deletions packages/adapter-static/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# houdini-adapter-static
55 changes: 55 additions & 0 deletions packages/adapter-static/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"name": "houdini-adapter-static",
"version": "1.2.49",
"description": "The adapter for deploying your Houdini application as a single-page application without a server component",
"keywords": [
"houdini",
"adpter",
"node"
],
"homepage": "https://github.com/HoudiniGraphql/houdini",
"funding": "https://github.com/sponsors/HoudiniGraphql",
"repository": {
"type": "git",
"url": "https://github.com/HoudiniGraphql/houdini.git"
},
"license": "MIT",
"type": "module",
"scripts": {
"build": "npx tsc src/* --allowJs --outDir build --module esnext --target esnext --allowSyntheticDefaultImports --declaration || exit 0 ",
"build:": "cd ../../ && ((run build && cd -) || (cd - && exit 1))",
"build:build": "pnpm build: && pnpm build"
},
"devDependencies": {
"@types/react-dom": "^18.3.0",
"@types/node": "^18.7.23",
"csstype": "^3.1.3",
"scripts": "workspace:^",
"tsup": "^7.2.0",
"typescript": "^5.5.4"
},
"dependencies": {
"houdini": "workspace:^",
"react": "19.0.0-rc-eb259b5d3b-20240605",
"react-dom": "19.0.0-rc-eb259b5d3b-20240605",
"vite": "^4.1.4"
},
"files": [
"build"
],
"exports": {
"./package.json": "./package.json",
".": {
"import": "./build/index.js",
"require": "./build/index.cjs"
}
},
"types": "./build/index.d.ts",
"typesVersions": {
"*": {
"app": [
"build/app.d.ts"
]
}
}
}
88 changes: 88 additions & 0 deletions packages/adapter-static/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// @ts-nocheck
import { fs, type Adapter } from 'houdini'
import path from 'node:path'
import React from 'react'
import ReactDOM from 'react-dom/server'

// in order to prepare the app as a single-page app, we have 2 create 2 additional files:
// - an index.js that imports the application and calls React.render. This file needs to be built by vite so it's passed with the includePaths option for an adapter
// - an index.html containing the static shell that wraps the application.
const adapter: Adapter = async ({ outDir }) => {
// the first thing we need to do is pull out the rendered html file into the root of the outDir
await fs.copyFile(
path.join(outDir, 'assets', '$houdini', 'temp', 'spa-shell', 'index.html'),
path.join(outDir, 'index.html')
)

try {
await fs.rmdir(path.join(outDir, 'assets', '$houdini'))
} catch {}
}

// make sure we include the app entry point in the bundle
adapter.includePaths = {
shell: '$houdini/temp/spa-shell/index.html',
}

// we dont want any server artifacts to be generated
adapter.disableServer = true

adapter.pre = async ({ config, outDir, conventions }) => {
// before we do anything, we need to ensure the user isn't using a local API
if (config.localSchema) {
throw new Error(
"Houdini's SPA adapter cannot be used if your project relies on a local schema"
)
}

process.env.HOUDINI_SECONDARY_BUILD = 'true'

const { build } = await import('vite')

const shellDir = conventions.temp_dir(config, 'spa-shell')

// before we can import and render the user's index file, we need to compile it with vite
await build({
build: {
emptyOutDir: false,
ssr: true,
rollupOptions: {
output: {
dir: shellDir,
entryFileNames: '[name].js',
},
},
lib: {
entry: {
shell: conventions.router_index_path(config),
},
formats: ['es'],
},
},
})

process.env.HOUDINI_SECONDARY_BUILD = 'false'

// now we can import the bundled shell
const { default: App } = await import(path.join(shellDir, 'shell.js'))

// render the index.jsx file to generate the static html that
// we can use to wrap the ajvascript application
let shellContents = ReactDOM.renderToStaticMarkup(
React.createElement(App, {
children: [
React.createElement('div', {
id: 'app',
}),
],
})
).replace(
'</head>',
"<script type='module' src='virtual:houdini/static-entry'></script></head>"
)

// write the shell to the outDir
await fs.writeFile(path.join(shellDir, 'index.html'), shellContents)
}

export default adapter
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^4.4.2",
"vitest": "^0.32.2"
"vitest": "^1.6.0"
},
"type": "module"
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ async function generate_query_wrapper(args: PageBundleInput) {

// compute the import path from this component to the user's project
const page_path = path.join(
args.config.pluginDirectory('houdini-router'),
args.config.pluginDirectory('houdini-react'),
'..',
'..',
'..',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export async function generate_fallbacks({

const fallback_path = routerConventions.fallback_unit_path(config, which, id)
const page_path = path.join(
config.pluginDirectory('houdini-router'),
config.pluginDirectory('houdini-react'),
'..',
'..',
'..',
Expand Down
Loading

0 comments on commit 9090197

Please sign in to comment.