Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for an src/api.ts file #1

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions examples/14_minimal-api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "waku-example",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "waku dev --with-ssr",
"build": "waku build --with-ssr",
"start": "waku start --with-ssr"
},
"dependencies": {
"react": "18.3.0-canary-4b2a1115a-20240202",
"react-dom": "18.3.0-canary-4b2a1115a-20240202",
"react-server-dom-webpack": "18.3.0-canary-4b2a1115a-20240202",
"waku": "0.19.2"
},
"devDependencies": {
"@types/react": "18.2.55",
"@types/react-dom": "18.2.19",
"typescript": "5.3.3"
}
}
18 changes: 18 additions & 0 deletions examples/14_minimal-api/src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { defineApi, defineApiHandler } from 'waku/server';

// api handler for start/dev commands
export const api = defineApi(async (honoApp, opts) => {
const { middlewareHandlers } = opts;

// Hack: struggled to come up with a RegExp that would match everything except /api
honoApp.use(':route{^(?!api).*}', ...middlewareHandlers);
honoApp.use('/', ...middlewareHandlers);

honoApp.get('/api', (c) => c.json({ hello: 'world' }));
});

// platform specific handling here
export default defineApiHandler(async (honoApp, opts) => {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs more thought, but the idea is to define platform specific handler logic here.

const apiApp = api(honoApp, opts);
return apiApp;
});
25 changes: 25 additions & 0 deletions examples/14_minimal-api/src/components/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Suspense } from 'react';

import { Counter } from './Counter.js';

const App = ({ name }: { name: string }) => {
return (
<div style={{ border: '3px red dashed', margin: '1em', padding: '1em' }}>
<title>Waku</title>
<h1>Hello {name}!!</h1>
<h3>This is a server component.</h3>
<Suspense fallback="Pending...">
<ServerMessage />
</Suspense>
<Counter />
<div>{new Date().toISOString()}</div>
</div>
);
};

const ServerMessage = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
return <p>Hello from server!</p>;
};

export default App;
14 changes: 14 additions & 0 deletions examples/14_minimal-api/src/components/Counter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use client';

import { useState } from 'react';

export const Counter = () => {
const [count, setCount] = useState(0);
return (
<div style={{ border: '3px blue dashed', margin: '1em', padding: '1em' }}>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
<h3>This is a client component.</h3>
</div>
);
};
28 changes: 28 additions & 0 deletions examples/14_minimal-api/src/entries.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { lazy } from 'react';
import { defineEntries } from 'waku/server';
import { Slot } from 'waku/client';

const App = lazy(() => import('./components/App.js'));

export default defineEntries(
// renderEntries
async (input) => {
return {
App: <App name={input || 'Waku'} />,
};
},
// getBuildConfig
async () => [{ pathname: '/', entries: [{ input: '' }] }],
// getSsrConfig
async (pathname) => {
switch (pathname) {
case '/':
return {
input: '',
body: <Slot id="App" />,
};
default:
return null;
}
},
);
17 changes: 17 additions & 0 deletions examples/14_minimal-api/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { Root, Slot } from 'waku/client';

const rootElement = (
<StrictMode>
<Root>
<Slot id="App" />
</Root>
</StrictMode>
);

if (import.meta.env.WAKU_HYDRATE) {
hydrateRoot(document.body, rootElement);
} else {
createRoot(document.body).render(rootElement);
}
14 changes: 14 additions & 0 deletions examples/14_minimal-api/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"strict": true,
"target": "esnext",
"downlevelIteration": true,
"esModuleInterop": true,
"module": "nodenext",
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"types": ["react/experimental"],
"jsx": "react-jsx"
}
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"examples:dev:11_form": "NAME=11_form pnpm run examples:dev",
"examples:dev:12_css": "NAME=12_css pnpm run examples:dev",
"examples:dev:13_path-alias": "NAME=13_path-alias pnpm run examples:dev",
"examples:dev:14_minimal-api": "NAME=14_minimal-api pnpm run examples:dev",
"examples:build": "(cd ./examples/${NAME} && pnpm run build)",
"examples:prd": "pnpm run examples:build && (cd ./examples/${NAME} && pnpm start)",
"examples:prd:01_template": "NAME=01_template pnpm run examples:prd",
Expand All @@ -39,6 +40,7 @@
"examples:prd:11_form": "NAME=11_form pnpm run examples:prd",
"examples:prd:12_css": "NAME=12_css pnpm run examples:prd",
"examples:prd:13_path-alias": "NAME=13_path-alias pnpm run examples:prd",
"examples:prd:14_minimal-api": "NAME=14_minimal-api pnpm run examples:prd",
"website:dev": "(cd packages/website && pnpm run dev)",
"website:build": "cd packages/website && pnpm run build",
"website:vercel": "pnpm run compile && pnpm run website:build --with-vercel-static && mv packages/website/.vercel/output .vercel/ && (cp -r README.md packages/website/contents .vercel/output/functions/RSC.func/ ; true)",
Expand Down Expand Up @@ -82,4 +84,4 @@
"vite": "5.0.12"
}
}
}
}
100 changes: 78 additions & 22 deletions packages/waku/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import path from 'node:path';
import { existsSync, writeFileSync, unlinkSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
import { parseArgs } from 'node:util';
import { createRequire } from 'node:module';
import { randomBytes } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import { Hono } from 'hono';
import type { Env } from 'hono';
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import * as swc from '@swc/core';
import dotenv from 'dotenv';

import type { Config } from './config.js';
import type { Api } from './server.js';
import { resolveConfig } from './lib/config.js';
import { honoMiddleware as honoDevMiddleware } from './lib/middleware/hono-dev.js';
import { honoMiddleware as honoPrdMiddleware } from './lib/middleware/hono-prd.js';
import { build } from './lib/builder/build.js';
import { extname } from './lib/utils/path.js';

const require = createRequire(new URL('.', import.meta.url));

Expand Down Expand Up @@ -92,11 +95,26 @@ if (values.version) {
}

async function runDev(options: { ssr: boolean }) {
const resolvedConfig = await resolveConfig(config);

const middlewareHandler = honoDevMiddleware<Env>({
...options,
config,
env: process.env as any,
});

const app = new Hono();
app.use(
'*',
honoDevMiddleware({ ...options, config, env: process.env as any }),
);
const api = await loadDevApi();
if (api) {
const apiOptions = {
config: resolvedConfig,
middlewareHandlers: [middlewareHandler],
};
await api(app, apiOptions);
} else {
app.use('*', middlewareHandler);
}

const port = parseInt(process.env.PORT || '3000', 10);
startServer(app, port);
}
Expand Down Expand Up @@ -124,20 +142,32 @@ async function runBuild(options: { ssr: boolean }) {
}

async function runStart(options: { ssr: boolean }) {
const { distDir, publicDir, entriesJs } = await resolveConfig(config);
const loadEntries = () =>
import(pathToFileURL(path.resolve(distDir, entriesJs)).toString());
const resolvedConfig = await resolveConfig(config);
const { distDir, publicDir, entriesJs } = resolvedConfig;

const staticServeMiddlewareHandler = serveStatic({
root: path.join(distDir, publicDir),
});
const prdMiddlewareHandler = honoPrdMiddleware<Env>({
...options,
config,
loadEntries: () =>
import(pathToFileURL(path.resolve(distDir, entriesJs)).toString()),
env: process.env as any,
});

const app = new Hono();
app.use('*', serveStatic({ root: path.join(distDir, publicDir) }));
app.use(
'*',
honoPrdMiddleware({
...options,
config,
loadEntries,
env: process.env as any,
}),
);
const api = await loadPrdApi();
if (api) {
const apiOptions = {
config: resolvedConfig,
middlewareHandlers: [staticServeMiddlewareHandler, prdMiddlewareHandler],
};
await api(app, apiOptions);
} else {
app.use('*', staticServeMiddlewareHandler);
app.use('*', prdMiddlewareHandler);
}
const port = parseInt(process.env.PORT || '8080', 10);
startServer(app, port);
}
Expand Down Expand Up @@ -177,22 +207,48 @@ Options:
`);
}

// TODO is this a good idea?
// TODO: should we call `loadApi` directly rather than splitting into two functions?
const loadPrdApi = () => loadApi('prd');
const loadDevApi = () => loadApi('dev');

async function loadApi(env: 'dev' | 'prd'): Promise<Api | null> {
const { apiJs } = await resolveConfig(config);
if (!apiJs) {
return null;
}
const apiJsPath =
env === 'prd'
? path.resolve('dist', `${apiJs.slice(0, -extname(apiJs).length)}.js`)
: path.resolve('src', apiJs);
if (!existsSync(apiJsPath)) {
return null;
}
if (apiJsPath.endsWith('.js')) {
return (await import(pathToFileURL(apiJsPath).toString())).api;
}
return (await loadTsModule(apiJsPath)).api;
}

async function loadConfig(): Promise<Config> {
if (!existsSync('waku.config.ts')) {
return {};
}
const { code } = swc.transformFileSync('waku.config.ts', {
return (await loadTsModule('waku.config.ts')).default;
}

async function loadTsModule(filePath: string): Promise<any> {
const { code } = swc.transformFileSync(filePath, {
swcrc: false,
jsc: {
parser: { syntax: 'typescript' },
target: 'es2022',
},
});

const temp = path.resolve(`.temp-${randomBytes(8).toString('hex')}.js`);
try {
writeFileSync(temp, code);
return (await import(pathToFileURL(temp).toString())).default;
return await import(pathToFileURL(temp).toString());
} finally {
unlinkSync(temp);
}
Expand Down
5 changes: 5 additions & 0 deletions packages/waku/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ export interface Config {
* Defaults to "entries.js".
*/
entriesJs?: string;
/**
* The custom api file relative to srcDir.
* Defaults to "api.ts".
*/
apiJs?: string;
/**
* The serve.js file relative distDir.
* This file is used for deployment.
Expand Down
11 changes: 11 additions & 0 deletions packages/waku/src/lib/builder/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ const buildServerBundle = async (
config: ResolvedConfig,
entriesFile: string,
distEntriesFile: string,
apiEntryFiles: Record<string, string>,
commonEntryFiles: Record<string, string>,
clientEntryFiles: Record<string, string>,
serverEntryFiles: Record<string, string>,
Expand Down Expand Up @@ -202,6 +203,7 @@ const buildServerBundle = async (
entries: entriesFile,
[RSDW_SERVER_MODULE]: RSDW_SERVER_MODULE_VALUE,
[WAKU_CLIENT]: CLIENT_MODULE_MAP[WAKU_CLIENT],
...apiEntryFiles,
...commonEntryFiles,
...clientEntryFiles,
...serverEntryFiles,
Expand Down Expand Up @@ -611,6 +613,14 @@ export async function build(options: {
const distEntriesFile = resolveFileName(
joinPath(rootDir, config.distDir, config.entriesJs),
);
const apiFile = resolveFileName(
joinPath(rootDir, config.srcDir, config.apiJs),
);
const apiEntryFiles = existsSync(apiFile)
? {
[config.apiJs.slice(0, -extname(config.apiJs).length)]: apiFile,
}
: {};

const { commonEntryFiles, clientEntryFiles, serverEntryFiles } =
await analyzeEntries(entriesFile);
Expand All @@ -619,6 +629,7 @@ export async function build(options: {
config,
entriesFile,
distEntriesFile,
apiEntryFiles,
commonEntryFiles,
clientEntryFiles,
serverEntryFiles,
Expand Down
1 change: 1 addition & 0 deletions packages/waku/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export async function resolveConfig(config: Config) {
indexHtml: 'index.html',
mainJs: 'main.tsx',
entriesJs: 'entries.js',
apiJs: 'api.ts',
serveJs: 'serve.js',
rscPath: 'RSC',
htmlHead: DEFAULT_HTML_HEAD,
Expand Down
Loading