@rshono/create 1.0.0-rc.4 → 1.0.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -8
- package/dist/api.mjs +1 -7
- package/dist/cli.mjs +2 -18
- package/package.json +2 -2
- package/templates/base/README.md +1 -5
- package/templates/base/_env +1 -4
- package/templates/base/rshono.config.ts +0 -28
- package/templates/base/src/components/404.tsx +0 -1
- package/templates/base/src/components/500.tsx +0 -6
- package/templates/base/src/components/home.tsx +6 -23
- package/templates/base/src/components/layout.tsx +4 -8
- package/templates/base/src/routes.ts +1 -26
- package/templates/base/src/server.ts +6 -35
- package/templates/base/src/styles.css +1 -64
- package/templates/biome/biome.json +1 -0
- package/templates/eslint/eslint.config.mjs +0 -15
- package/templates/oxfmt/_oxfmtrc.json +1 -1
- package/templates/tailwind/postcss.config.mjs +0 -4
- package/templates/tailwind/rshono.config.ts +1 -35
- package/templates/tailwind/src/components/home.tsx +6 -23
- package/templates/tailwind/src/components/layout.tsx +4 -8
- package/templates/tailwind/src/styles.css +0 -25
- package/templates/base/src/actions.ts +0 -22
- package/templates/base/src/components/greet-form.tsx +0 -27
- package/templates/base/src/lib/env.ts +0 -26
package/README.md
CHANGED
|
@@ -50,21 +50,20 @@ presets — a target added to rshono appears here with no edit.
|
|
|
50
50
|
## What you get
|
|
51
51
|
|
|
52
52
|
```
|
|
53
|
-
rshono.config.ts the chosen deploy target;
|
|
53
|
+
rshono.config.ts the chosen deploy target; every other setting takes its default
|
|
54
54
|
tsconfig.json strict, with @/* → ./src/*
|
|
55
55
|
.env committed defaults; secrets go in .env.local
|
|
56
56
|
public/ favicon.svg, robots.txt
|
|
57
|
-
src/routes.ts one page, a 404 and a 500
|
|
58
|
-
src/server.ts
|
|
59
|
-
src/
|
|
60
|
-
src/components/ layout, home, greet-form ('use client'), 404, 500
|
|
61
|
-
src/lib/env.ts both sides of the PUBLIC_ boundary in one place
|
|
57
|
+
src/routes.ts one page, a 404 and a 500
|
|
58
|
+
src/server.ts error reporting, trailing-slash and old-path redirects, AppType
|
|
59
|
+
src/components/ layout, home, 404, 500
|
|
62
60
|
src/styles.css element-level CSS, or the Tailwind entry
|
|
63
61
|
pnpm-workspace.yaml pnpm only: which dependency install scripts this app runs (none of them)
|
|
64
62
|
```
|
|
65
63
|
|
|
66
|
-
|
|
67
|
-
project passes its own `format:check`
|
|
64
|
+
Scaffolding runs the install and nothing else — no formatter, no linter. The templates are written to
|
|
65
|
+
satisfy every formatter option at its configured width, so a fresh project passes its own `format:check`
|
|
66
|
+
without a write pass first.
|
|
68
67
|
|
|
69
68
|
`react` and `react-dom` are pinned **exactly**, at the versions the framework is tested against, and those
|
|
70
69
|
pins are generated from rshono's own manifest. That is not tidiness: the RSC runtime reaches into React's
|
package/dist/api.mjs
CHANGED
|
@@ -79,7 +79,7 @@ __webpack_require__.d(__webpack_exports__, {
|
|
|
79
79
|
|
|
80
80
|
;// CONCATENATED MODULE: ./src/generated/framework.ts
|
|
81
81
|
// GENERATED by scripts/codegen.mjs from packages/core — do not edit. Run `pnpm --filter @rshono/create codegen`.
|
|
82
|
-
/** The framework release a scaffolded app is pinned to: this package and rshono ship together. */ const RSHONO_VERSION = '1.0.0-rc.
|
|
82
|
+
/** The framework release a scaffolded app is pinned to: this package and rshono ship together. */ const RSHONO_VERSION = '1.0.0-rc.6';
|
|
83
83
|
/**
|
|
84
84
|
* The Node range rshono itself declares, restated in every scaffolded app's `engines` — so a CI image
|
|
85
85
|
* or a contributor on an older Node hears it from their package manager rather than from a stack trace.
|
|
@@ -743,12 +743,6 @@ function packageManager(name, version) {
|
|
|
743
743
|
*/ function runInstall(pm, cwd) {
|
|
744
744
|
return run(pm, pm.install, cwd);
|
|
745
745
|
}
|
|
746
|
-
/** `<pm> run <script>` — the one form every package manager accepts, Yarn v1 included. */ function runScript(pm, script, cwd) {
|
|
747
|
-
return run(pm, [
|
|
748
|
-
'run',
|
|
749
|
-
script
|
|
750
|
-
], cwd);
|
|
751
|
-
}
|
|
752
746
|
function run(pm, args, cwd) {
|
|
753
747
|
const result = spawnSync(pm.name, args, {
|
|
754
748
|
cwd,
|
package/dist/cli.mjs
CHANGED
|
@@ -2943,7 +2943,7 @@ function hasGit(cwd) {
|
|
|
2943
2943
|
|
|
2944
2944
|
;// CONCATENATED MODULE: ./src/generated/framework.ts
|
|
2945
2945
|
// GENERATED by scripts/codegen.mjs from packages/core — do not edit. Run `pnpm --filter @rshono/create codegen`.
|
|
2946
|
-
/** The framework release a scaffolded app is pinned to: this package and rshono ship together. */ const RSHONO_VERSION = '1.0.0-rc.
|
|
2946
|
+
/** The framework release a scaffolded app is pinned to: this package and rshono ship together. */ const RSHONO_VERSION = '1.0.0-rc.6';
|
|
2947
2947
|
/**
|
|
2948
2948
|
* The Node range rshono itself declares, restated in every scaffolded app's `engines` — so a CI image
|
|
2949
2949
|
* or a contributor on an older Node hears it from their package manager rather than from a stack trace.
|
|
@@ -3628,12 +3628,6 @@ function packageManager(name, version) {
|
|
|
3628
3628
|
*/ function runInstall(pm, cwd) {
|
|
3629
3629
|
return run(pm, pm.install, cwd);
|
|
3630
3630
|
}
|
|
3631
|
-
/** `<pm> run <script>` — the one form every package manager accepts, Yarn v1 included. */ function runScript(pm, script, cwd) {
|
|
3632
|
-
return run(pm, [
|
|
3633
|
-
'run',
|
|
3634
|
-
script
|
|
3635
|
-
], cwd);
|
|
3636
|
-
}
|
|
3637
3631
|
function run(pm, args, cwd) {
|
|
3638
3632
|
const result = spawnSync(pm.name, args, {
|
|
3639
3633
|
cwd,
|
|
@@ -3846,7 +3840,7 @@ function fail(message) {
|
|
|
3846
3840
|
async function main() {
|
|
3847
3841
|
const { values, positionals } = parse();
|
|
3848
3842
|
if (values.help) return console.log(HELP);
|
|
3849
|
-
if (values.version) return console.log("1.0.0-rc.
|
|
3843
|
+
if (values.version) return console.log("1.0.0-rc.6");
|
|
3850
3844
|
/*
|
|
3851
3845
|
* A pipe, a CI job or an agent gets the defaults rather than a prompt nothing can answer. Both streams
|
|
3852
3846
|
* have to be a terminal: the prompts draw on stdout but *read from stdin*, so `echo | npx @rshono/create`
|
|
@@ -3986,16 +3980,6 @@ async function main() {
|
|
|
3986
3980
|
installed = runInstall(pm, targetDir);
|
|
3987
3981
|
if (!installed) log.warn(`${pm.name} install failed — the files are all written, so run it yourself in the project.`);
|
|
3988
3982
|
}
|
|
3989
|
-
/*
|
|
3990
|
-
* Format the scaffold with the tool it was scaffolded with, so a fresh project passes its own
|
|
3991
|
-
* `format:check` instead of reporting a diff nobody made. Needs the install, since the formatter is a
|
|
3992
|
-
* devDependency — hence the skip, rather than a failure, when there is none.
|
|
3993
|
-
*
|
|
3994
|
-
* Whether there is a `format` script is the plan's answer, not the formatter answer's: `--formatter
|
|
3995
|
-
* none --linter biome` has no formatter and a `format` script all the same, because Biome brings one.
|
|
3996
|
-
*/ if (installed && plan.features.some((feature)=>feature.scripts?.format)) {
|
|
3997
|
-
if (!runScript(pm, 'format', targetDir)) log.warn(`\`${pm.run} format\` failed — the scaffold is written, just not formatted.`);
|
|
3998
|
-
}
|
|
3999
3983
|
if (git) {
|
|
4000
3984
|
if (!hasGit(targetDir)) {
|
|
4001
3985
|
log.warn('git was not found on PATH — skipped.');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rshono/create",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.6",
|
|
4
4
|
"description": "Scaffold a new rshono app — Hono + Rspack + React Server Components",
|
|
5
5
|
"author": "Lasse <lasse@lassetange.com> (https://www.lassetange.com)",
|
|
6
6
|
"license": "ISC",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@rspack/core": "2.1.5",
|
|
47
47
|
"@types/node": "^26.1.1",
|
|
48
48
|
"typescript": "^7.0.2",
|
|
49
|
-
"@rshono/core": "1.0.0-rc.
|
|
49
|
+
"@rshono/core": "1.0.0-rc.6"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
52
|
"build": "node scripts/codegen.mjs && node scripts/build.mjs",
|
package/templates/base/README.md
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# {{PROJECT_NAME}}
|
|
2
2
|
|
|
3
|
-
A [rshono](https://github.com/rshono/rshono) app — [Hono](https://hono.dev) + [Rspack](https://rspack.rs) + [React Server Components](https://react.dev/reference/rsc/server-components).
|
|
4
|
-
|
|
5
3
|
```bash
|
|
6
4
|
{{PM_RUN}} dev # dev server with HMR, http://localhost:3000
|
|
7
5
|
{{PM_RUN}} build # production build for {{DEPLOY_TARGET}}
|
|
@@ -18,9 +16,7 @@ public/ served verbatim at the web root (favicon.svg → /favicon.svg
|
|
|
18
16
|
src/
|
|
19
17
|
routes.ts the route table — the one file rshono requires
|
|
20
18
|
server.ts a Hono app: middleware, API routes, redirects, error reporting
|
|
21
|
-
actions.ts 'use server' functions the browser can call
|
|
22
19
|
components/ pages and components
|
|
23
|
-
lib/ everything else
|
|
24
20
|
styles.css imported by the layout, so it loads with the page
|
|
25
21
|
```
|
|
26
22
|
|
|
@@ -31,7 +27,7 @@ Interactive parts are `'use client'` components a page imports — only those sh
|
|
|
31
27
|
|
|
32
28
|
`.env` holds committed defaults; `.env.local` overrides it and is gitignored. Only `PUBLIC_`-prefixed
|
|
33
29
|
variables reach the browser — everything else is server-only, and a stray read of it in client code
|
|
34
|
-
compiles to `undefined` rather than shipping. `src/
|
|
30
|
+
compiles to `undefined` rather than shipping. `src/components/layout.tsx` reads `PUBLIC_APP_NAME` that way.
|
|
35
31
|
|
|
36
32
|
## Deploying
|
|
37
33
|
|
package/templates/base/_env
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
#
|
|
2
|
-
#
|
|
3
|
-
# Secrets go in `.env.local`, which is gitignored and loaded first, so a value there wins over this
|
|
4
|
-
# file. The real environment wins over both.
|
|
1
|
+
# Every variable here is on `process.env` — the layout reads `process.env.PUBLIC_APP_NAME`.
|
|
5
2
|
#
|
|
6
3
|
# Only `PUBLIC_`-prefixed variables are compiled into the browser bundle. Anything else is server-only:
|
|
7
4
|
# a stray read of it in client code compiles to `undefined` rather than shipping.
|
|
@@ -1,33 +1,5 @@
|
|
|
1
1
|
import { defineConfig } from '@rshono/core';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Every field is optional — delete this file to accept all the defaults. The commented lines are the
|
|
5
|
-
* defaults, kept as documentation of what is there to change.
|
|
6
|
-
*
|
|
7
|
-
* The framework settings are compiled into the server bundle at build time, so changing one means a
|
|
8
|
-
* rebuild; there is no env-var interface for them. `--port`/`PORT` and `HOST` are the two exceptions,
|
|
9
|
-
* and they win over what is written here.
|
|
10
|
-
*/
|
|
11
3
|
export default defineConfig({
|
|
12
|
-
/** Where `build` targets. Overridable per build with `--deploy` or `RSHONO_DEPLOY`. */
|
|
13
4
|
deploy: '{{DEPLOY_TARGET}}',
|
|
14
|
-
|
|
15
|
-
// The public origin, baked into prerendered pages' absolute URLs. Set it if you use `render: 'static'`
|
|
16
|
-
// and build canonical tags, `og:url` or absolute links — there is no request to read a Host from.
|
|
17
|
-
// siteUrl: 'https://example.com',
|
|
18
|
-
|
|
19
|
-
// port: 3000, // default for dev/start
|
|
20
|
-
// host: '0.0.0.0', // bind address for start
|
|
21
|
-
|
|
22
|
-
// trustProxy: false, // honour X-Forwarded-Host/-Proto — only behind a proxy you control
|
|
23
|
-
// checkOrigin: true, // CSRF origin check on server-action POSTs
|
|
24
|
-
// allowedOrigins: [], // extra origins allowed to post actions
|
|
25
|
-
// csp: false, // strict per-request-nonce Content-Security-Policy
|
|
26
|
-
// cspDirectives: {}, // widen it, e.g. { 'img-src': "'self' https://cdn.example.com" }
|
|
27
|
-
// bodySizeLimit: '1mb',// request body cap before a 413; false disables it
|
|
28
|
-
|
|
29
|
-
// Escape hatch: mutate the generated Rspack config just before it compiles. Called once per compiler.
|
|
30
|
-
// rspack(config, { isServer, isDev }) {
|
|
31
|
-
// return config;
|
|
32
|
-
// },
|
|
33
5
|
});
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { PageProps } from '@rshono/core';
|
|
2
2
|
import { Layout } from './layout';
|
|
3
3
|
|
|
4
|
-
/** Declared as `notFound` in routes.ts. Answers unmatched paths, and any `notFound()` call, with a 404. */
|
|
5
4
|
export default function NotFound({ url }: PageProps) {
|
|
6
5
|
return (
|
|
7
6
|
<Layout title="Not found">
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import type { ErrorPageProps } from '@rshono/core';
|
|
2
2
|
import { Layout } from './layout';
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* Declared as `error` in routes.ts. Answers a request that threw, with a 500.
|
|
6
|
-
*
|
|
7
|
-
* `error.message` is the real message in development and a generic `'Internal Server Error'` in
|
|
8
|
-
* production, where `error.stack` is absent — so this component can show it without leaking anything.
|
|
9
|
-
*/
|
|
10
4
|
export default function ServerError({ error }: ErrorPageProps) {
|
|
11
5
|
return (
|
|
12
6
|
<Layout title="Something went wrong">
|
|
@@ -1,29 +1,14 @@
|
|
|
1
1
|
import type { PageProps } from '@rshono/core';
|
|
2
|
-
import {
|
|
3
|
-
import type { AppEnv } from '../server';
|
|
4
|
-
import { GreetForm } from './greet-form';
|
|
5
|
-
import { Layout } from './layout';
|
|
2
|
+
import { appName, Layout } from './layout';
|
|
6
3
|
|
|
7
|
-
|
|
8
|
-
* A page is a React **server component**: it runs on the server only, may be `async`, and can await data
|
|
9
|
-
* directly — no loaders, no client bundle for any of this.
|
|
10
|
-
*
|
|
11
|
-
* The `AppEnv` type argument is what types `ctx.var` key by key; without it `ctx.var` is an open record.
|
|
12
|
-
*/
|
|
13
|
-
export default function Home({ url, ctx }: PageProps<'/', AppEnv>) {
|
|
4
|
+
export default function Home({ url }: PageProps<'/'>) {
|
|
14
5
|
return (
|
|
15
6
|
<Layout description="A new rshono app.">
|
|
16
|
-
<h1>{
|
|
7
|
+
<h1>{appName}</h1>
|
|
17
8
|
<p>
|
|
18
|
-
Edit <code>src/components/home.tsx</code> and save
|
|
9
|
+
Edit <code>src/components/home.tsx</code> and save — the page re-renders in place.
|
|
19
10
|
</p>
|
|
20
11
|
|
|
21
|
-
<h2>Server actions</h2>
|
|
22
|
-
<p>
|
|
23
|
-
This form calls a <code>'use server'</code> function in <code>src/actions.ts</code>. It works before hydration and with JavaScript disabled.
|
|
24
|
-
</p>
|
|
25
|
-
<GreetForm />
|
|
26
|
-
|
|
27
12
|
<h2>Where things are</h2>
|
|
28
13
|
<ul>
|
|
29
14
|
<li>
|
|
@@ -33,7 +18,7 @@ export default function Home({ url, ctx }: PageProps<'/', AppEnv>) {
|
|
|
33
18
|
<code>src/server.ts</code> — a Hono app for middleware and API routes, mounted ahead of the pages
|
|
34
19
|
</li>
|
|
35
20
|
<li>
|
|
36
|
-
<code>src/components/</code> — pages and components
|
|
21
|
+
<code>src/components/</code> — pages and components
|
|
37
22
|
</li>
|
|
38
23
|
<li>
|
|
39
24
|
<code>rshono.config.ts</code> — deploy target, security and build settings
|
|
@@ -41,9 +26,7 @@ export default function Home({ url, ctx }: PageProps<'/', AppEnv>) {
|
|
|
41
26
|
</ul>
|
|
42
27
|
|
|
43
28
|
<p>
|
|
44
|
-
|
|
45
|
-
prop, so reading it needs no import. It is server-only and never crosses into a client component. */}
|
|
46
|
-
Rendered on the server for <code>{url.pathname}</code>, request <code>{ctx.var.requestId}</code>.
|
|
29
|
+
Rendered on the server for <code>{url.pathname}</code>.
|
|
47
30
|
</p>
|
|
48
31
|
</Layout>
|
|
49
32
|
);
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import { publicEnv } from '../lib/env';
|
|
3
2
|
import '../styles.css';
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
* in. Importing the stylesheet here is what attaches it to each of those pages.
|
|
8
|
-
*/
|
|
4
|
+
export const appName = process.env.PUBLIC_APP_NAME ?? '{{PROJECT_NAME}}';
|
|
5
|
+
|
|
9
6
|
export function Layout({ title, description, children }: { title?: string; description?: string; children: ReactNode }) {
|
|
10
|
-
const heading = title ? `${title} · ${
|
|
7
|
+
const heading = title ? `${title} · ${appName}` : appName;
|
|
11
8
|
|
|
12
9
|
return (
|
|
13
10
|
<html lang="en">
|
|
@@ -22,9 +19,8 @@ export function Layout({ title, description, children }: { title?: string; descr
|
|
|
22
19
|
<header>
|
|
23
20
|
<nav>
|
|
24
21
|
<a href="/">
|
|
25
|
-
<strong>{
|
|
22
|
+
<strong>{appName}</strong>
|
|
26
23
|
</a>
|
|
27
|
-
{/* `data-native` opts a link out of soft navigation and does a full browser load. */}
|
|
28
24
|
<a href="/api/health" data-native>
|
|
29
25
|
/api/health
|
|
30
26
|
</a>
|
|
@@ -1,32 +1,7 @@
|
|
|
1
1
|
import { defineRoutes } from '@rshono/core';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* The one file rshono requires. It only ever runs on the server, so importing server-only modules from
|
|
5
|
-
* it — inside `staticPaths`, say — is safe.
|
|
6
|
-
*
|
|
7
|
-
* Write each page as the inline `component: () => import('…')` thunk you see below: the build detects
|
|
8
|
-
* that exact form and attaches the page's own JS and CSS to it, which is what makes assets code-split
|
|
9
|
-
* per route.
|
|
10
|
-
*/
|
|
11
3
|
export const routes = defineRoutes({
|
|
12
|
-
routes: [
|
|
13
|
-
{ path: '/', component: () => import('./components/home') },
|
|
14
|
-
|
|
15
|
-
// A page with params. `PageProps<'/posts/:slug'>` types `params.slug` for the component.
|
|
16
|
-
// { path: '/posts/:slug', component: () => import('./components/post') },
|
|
17
|
-
|
|
18
|
-
// Prerendered at build time, one file per param set, served from disk.
|
|
19
|
-
// {
|
|
20
|
-
// path: '/docs/:slug',
|
|
21
|
-
// render: 'static',
|
|
22
|
-
// component: () => import('./components/doc'),
|
|
23
|
-
// staticPaths: async () => [{ slug: 'getting-started' }],
|
|
24
|
-
// },
|
|
25
|
-
|
|
26
|
-
// A raw Hono handler instead of a page — for anything that isn't HTML. The other place for these
|
|
27
|
-
// is src/server.ts, which is the better home for a group of them.
|
|
28
|
-
// { type: 'endpoint', path: '/api/posts', server: () => import('./api/posts') },
|
|
29
|
-
],
|
|
4
|
+
routes: [{ path: '/', component: () => import('./components/home') }],
|
|
30
5
|
notFound: { component: () => import('./components/404') },
|
|
31
6
|
error: { component: () => import('./components/500') },
|
|
32
7
|
});
|
|
@@ -3,20 +3,12 @@ import { Hono } from 'hono';
|
|
|
3
3
|
import { trimTrailingSlash } from 'hono/trailing-slash';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `components/home.tsx`.
|
|
6
|
+
* Mounted at `/` ahead of the page routes, so middleware registered here wraps page requests too — auth,
|
|
7
|
+
* logging, headers. The flip side: a terminal handler at a page's path shadows the page.
|
|
9
8
|
*/
|
|
10
|
-
|
|
9
|
+
const server = new Hono();
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
const startedAt = Date.now();
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Where an error tracker goes. Registered at module load — this file is imported as the server starts —
|
|
17
|
-
* so every error the framework catches reaches one place: a thrown action, a failed render, SSR falling
|
|
18
|
-
* over. Swap the log for `Sentry.captureException(error)` or whatever you use.
|
|
19
|
-
*/
|
|
11
|
+
/** Every error the framework catches lands here: a thrown action, a failed render, SSR falling over. */
|
|
20
12
|
onServerError((error, { source, request }) => {
|
|
21
13
|
const message = error instanceof Error ? error.message : String(error);
|
|
22
14
|
console.error(`[error] ${source} ${new URL(request.url).pathname}: ${message}`);
|
|
@@ -25,35 +17,14 @@ onServerError((error, { source, request }) => {
|
|
|
25
17
|
/** `/about/` and `/about` should not be two pages. */
|
|
26
18
|
server.use(trimTrailingSlash({ alwaysRedirect: true }));
|
|
27
19
|
|
|
28
|
-
/**
|
|
29
|
-
* This sub-app is mounted at `/` *ahead of* the page routes, so middleware registered here wraps page
|
|
30
|
-
* requests too — auth, logging, headers. The flip side: a terminal handler at the same path as a page
|
|
31
|
-
* route shadows the page.
|
|
32
|
-
*/
|
|
33
|
-
server.use('*', async (c, next) => {
|
|
34
|
-
c.set('requestId', crypto.randomUUID());
|
|
35
|
-
const start = performance.now();
|
|
36
|
-
await next();
|
|
37
|
-
c.res.headers.set('X-Response-Time', `${(performance.now() - start).toFixed(1)} ms`);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
20
|
/** Old paths that should keep working. One place to add to, rather than a handler each. */
|
|
41
|
-
const REDIRECTS: Record<string, string> = {
|
|
42
|
-
'/home': '/',
|
|
43
|
-
};
|
|
21
|
+
const REDIRECTS: Record<string, string> = { '/home': '/' };
|
|
44
22
|
|
|
45
23
|
for (const [from, to] of Object.entries(REDIRECTS)) {
|
|
46
24
|
server.get(from, (c) => c.redirect(to, 301));
|
|
47
25
|
}
|
|
48
26
|
|
|
49
|
-
server.get('/api/health', (c) => {
|
|
50
|
-
return c.json({ status: 'ok', uptime: (Date.now() - startedAt) / 1000, requestId: c.var.requestId });
|
|
51
|
-
});
|
|
52
|
-
|
|
53
27
|
export default server;
|
|
54
28
|
|
|
55
|
-
/**
|
|
56
|
-
* End-to-end types for a client that calls this app: `hc<AppType>('/')` from `hono/client` gives typed
|
|
57
|
-
* paths, params and responses, checked against the handlers above.
|
|
58
|
-
*/
|
|
29
|
+
/** `hc<AppType>('/')` from `hono/client` gives paths, params and responses typed against the handlers above. */
|
|
59
30
|
export type AppType = typeof server;
|
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Deliberately element-level: nothing here names a class, so the markup in src/components stays plain
|
|
3
|
-
* HTML you can restyle or replace wholesale without hunting for selectors.
|
|
4
|
-
*
|
|
5
|
-
* Rspack compiles this natively — there is no PostCSS anywhere in the framework. If you want a plugin
|
|
6
|
-
* chain, add the loader in `rshono.config.ts`'s `rspack` hook; that, plus the packages, is all the
|
|
7
|
-
* Tailwind setup is.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
1
|
*,
|
|
11
2
|
*::before,
|
|
12
3
|
*::after {
|
|
@@ -38,12 +29,7 @@
|
|
|
38
29
|
}
|
|
39
30
|
|
|
40
31
|
body {
|
|
41
|
-
font-family:
|
|
42
|
-
ui-sans-serif,
|
|
43
|
-
system-ui,
|
|
44
|
-
-apple-system,
|
|
45
|
-
'Segoe UI',
|
|
46
|
-
sans-serif;
|
|
32
|
+
font-family: ui-sans-serif, system-ui, 'Segoe UI', sans-serif;
|
|
47
33
|
line-height: 1.6;
|
|
48
34
|
color: var(--text);
|
|
49
35
|
background: var(--bg);
|
|
@@ -127,52 +113,3 @@ pre {
|
|
|
127
113
|
background: var(--surface);
|
|
128
114
|
font-size: 0.8125rem;
|
|
129
115
|
}
|
|
130
|
-
|
|
131
|
-
form {
|
|
132
|
-
display: flex;
|
|
133
|
-
flex-wrap: wrap;
|
|
134
|
-
align-items: center;
|
|
135
|
-
gap: 0.5rem;
|
|
136
|
-
padding: 1rem;
|
|
137
|
-
border: 1px solid var(--border);
|
|
138
|
-
border-radius: var(--radius);
|
|
139
|
-
background: var(--surface);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
label {
|
|
143
|
-
flex-basis: 100%;
|
|
144
|
-
font-size: 0.875rem;
|
|
145
|
-
color: var(--muted);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
input {
|
|
149
|
-
flex: 1 1 12rem;
|
|
150
|
-
padding: 0.5rem 0.75rem;
|
|
151
|
-
border: 1px solid var(--border);
|
|
152
|
-
border-radius: 6px;
|
|
153
|
-
background: var(--bg);
|
|
154
|
-
color: inherit;
|
|
155
|
-
font: inherit;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
button {
|
|
159
|
-
padding: 0.5rem 1rem;
|
|
160
|
-
border: 0;
|
|
161
|
-
border-radius: 6px;
|
|
162
|
-
background: var(--accent);
|
|
163
|
-
color: #fff;
|
|
164
|
-
font: inherit;
|
|
165
|
-
font-weight: 500;
|
|
166
|
-
cursor: pointer;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
button:disabled {
|
|
170
|
-
opacity: 0.6;
|
|
171
|
-
cursor: progress;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
output {
|
|
175
|
-
flex-basis: 100%;
|
|
176
|
-
font-size: 0.9375rem;
|
|
177
|
-
color: var(--muted);
|
|
178
|
-
}
|
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
"files": { "includes": ["**", "!dist", "!.wrangler", "!.vercel", "!.netlify", "!wrangler.jsonc"] },
|
|
4
4
|
"formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 140 },
|
|
5
5
|
"javascript": { "formatter": { "quoteStyle": "single", "jsxQuoteStyle": "double" } },
|
|
6
|
+
"css": { "formatter": { "quoteStyle": "single" } },
|
|
6
7
|
"linter": { "enabled": true, "rules": { "preset": "recommended" } }
|
|
7
8
|
}
|
|
@@ -2,30 +2,15 @@ import js from '@eslint/js';
|
|
|
2
2
|
import reactHooks from 'eslint-plugin-react-hooks';
|
|
3
3
|
import tseslint from 'typescript-eslint';
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* Flat config, with type-aware rules — the reason to run ESLint over a syntax-only linter. `projectService`
|
|
7
|
-
* builds the same program `tsc` does from tsconfig.json, so a rule can ask what a value actually *is*:
|
|
8
|
-
* an unawaited promise, a `catch` that swallows an error, a `String()` around something that is not one.
|
|
9
|
-
*
|
|
10
|
-
* The cost is that ESLint needs TypeScript to answer, which is why this app pins TypeScript 6 —
|
|
11
|
-
* typescript-eslint reads the compiler API directly and accepts nothing newer. `npm run typecheck` is
|
|
12
|
-
* still the thing that decides whether the app compiles; these rules only see what it sees.
|
|
13
|
-
*/
|
|
14
5
|
export default tseslint.config(
|
|
15
|
-
// ESLint's own default ignores cover node_modules and nothing else, so the build output — and whatever
|
|
16
|
-
// the deploy target leaves beside it — would otherwise be linted as if you had written it.
|
|
17
6
|
{ ignores: ['dist/**', '.wrangler/**', '.vercel/**', '.netlify/**'] },
|
|
18
7
|
js.configs.recommended,
|
|
19
8
|
tseslint.configs.recommendedTypeChecked,
|
|
20
|
-
// The rules of hooks: the one class of React mistake no type checker catches, and the reason a React
|
|
21
|
-
// app wants a linter at all.
|
|
22
9
|
reactHooks.configs.flat['recommended-latest'],
|
|
23
10
|
{
|
|
24
11
|
languageOptions: {
|
|
25
12
|
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
|
|
26
13
|
},
|
|
27
14
|
},
|
|
28
|
-
// This file and any other plain JavaScript sits outside the TypeScript program, so the type-aware rules
|
|
29
|
-
// have nothing to run against and would report every file as unconfigured.
|
|
30
15
|
{ files: ['**/*.{js,mjs,cjs}'], extends: [tseslint.configs.disableTypeChecked] },
|
|
31
16
|
);
|
|
@@ -1,42 +1,8 @@
|
|
|
1
1
|
import { defineConfig } from '@rshono/core';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Every field is optional — delete this file to accept all the defaults. The commented lines are the
|
|
5
|
-
* defaults, kept as documentation of what is there to change.
|
|
6
|
-
*
|
|
7
|
-
* The framework settings are compiled into the server bundle at build time, so changing one means a
|
|
8
|
-
* rebuild; there is no env-var interface for them. `--port`/`PORT` and `HOST` are the two exceptions,
|
|
9
|
-
* and they win over what is written here.
|
|
10
|
-
*/
|
|
11
3
|
export default defineConfig({
|
|
12
|
-
/** Where `build` targets. Overridable per build with `--deploy` or `RSHONO_DEPLOY`. */
|
|
13
4
|
deploy: '{{DEPLOY_TARGET}}',
|
|
14
|
-
|
|
15
|
-
// The public origin, baked into prerendered pages' absolute URLs. Set it if you use `render: 'static'`
|
|
16
|
-
// and build canonical tags, `og:url` or absolute links — there is no request to read a Host from.
|
|
17
|
-
// siteUrl: 'https://example.com',
|
|
18
|
-
|
|
19
|
-
// port: 3000, // default for dev/start
|
|
20
|
-
// host: '0.0.0.0', // bind address for start
|
|
21
|
-
|
|
22
|
-
// trustProxy: false, // honour X-Forwarded-Host/-Proto — only behind a proxy you control
|
|
23
|
-
// checkOrigin: true, // CSRF origin check on server-action POSTs
|
|
24
|
-
// allowedOrigins: [], // extra origins allowed to post actions
|
|
25
|
-
// csp: false, // strict per-request-nonce Content-Security-Policy
|
|
26
|
-
// cspDirectives: {}, // widen it, e.g. { 'img-src': "'self' https://cdn.example.com" }
|
|
27
|
-
// bodySizeLimit: '1mb',// request body cap before a 413; false disables it
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Tailwind, and the only thing the build needs to know about it.
|
|
31
|
-
*
|
|
32
|
-
* Rspack compiles CSS natively, which is fast and is all a plain stylesheet needs — but that parser
|
|
33
|
-
* reads *finished* CSS, and `@import 'tailwindcss'`, `@theme` and `@apply` are not that. Tailwind is a
|
|
34
|
-
* PostCSS plugin, so it has to run in front of the parser, which is what this rule does. The plugin
|
|
35
|
-
* list itself is in `postcss.config.mjs`, where postcss-loader looks for it.
|
|
36
|
-
*
|
|
37
|
-
* The hook is called once per compiler, so the rule reaches the client and the server graph both.
|
|
38
|
-
* Delete all of this, and the four Tailwind packages, to go back to plain CSS.
|
|
39
|
-
*/
|
|
5
|
+
/** Tailwind is a PostCSS plugin, so postcss-loader has to run ahead of Rspack's native CSS parser. */
|
|
40
6
|
rspack(config) {
|
|
41
7
|
config.module!.rules!.push({ test: /\.css$/i, use: ['postcss-loader'], type: 'css/auto' });
|
|
42
8
|
},
|
|
@@ -1,29 +1,14 @@
|
|
|
1
1
|
import type { PageProps } from '@rshono/core';
|
|
2
|
-
import {
|
|
3
|
-
import type { AppEnv } from '../server';
|
|
4
|
-
import { GreetForm } from './greet-form';
|
|
5
|
-
import { Layout } from './layout';
|
|
2
|
+
import { appName, Layout } from './layout';
|
|
6
3
|
|
|
7
|
-
|
|
8
|
-
* A page is a React **server component**: it runs on the server only, may be `async`, and can await data
|
|
9
|
-
* directly — no loaders, no client bundle for any of this.
|
|
10
|
-
*
|
|
11
|
-
* The `AppEnv` type argument is what types `ctx.var` key by key; without it `ctx.var` is an open record.
|
|
12
|
-
*/
|
|
13
|
-
export default function Home({ url, ctx }: PageProps<'/', AppEnv>) {
|
|
4
|
+
export default function Home({ url }: PageProps<'/'>) {
|
|
14
5
|
return (
|
|
15
6
|
<Layout description="A new rshono app.">
|
|
16
|
-
<h1 className="mb-2 text-4xl font-semibold tracking-tight">{
|
|
7
|
+
<h1 className="mb-2 text-4xl font-semibold tracking-tight">{appName}</h1>
|
|
17
8
|
<p className="mb-4">
|
|
18
|
-
Edit <code>src/components/home.tsx</code> and save
|
|
9
|
+
Edit <code>src/components/home.tsx</code> and save — the page re-renders in place.
|
|
19
10
|
</p>
|
|
20
11
|
|
|
21
|
-
<h2 className="mt-10 mb-2 text-lg font-semibold">Server actions</h2>
|
|
22
|
-
<p className="mb-4">
|
|
23
|
-
This form calls a <code>'use server'</code> function in <code>src/actions.ts</code>. It works before hydration and with JavaScript disabled.
|
|
24
|
-
</p>
|
|
25
|
-
<GreetForm />
|
|
26
|
-
|
|
27
12
|
<h2 className="mt-10 mb-2 text-lg font-semibold">Where things are</h2>
|
|
28
13
|
<ul className="mb-4 list-disc space-y-1 pl-5">
|
|
29
14
|
<li>
|
|
@@ -33,7 +18,7 @@ export default function Home({ url, ctx }: PageProps<'/', AppEnv>) {
|
|
|
33
18
|
<code>src/server.ts</code> — a Hono app for middleware and API routes, mounted ahead of the pages
|
|
34
19
|
</li>
|
|
35
20
|
<li>
|
|
36
|
-
<code>src/components/</code> — pages and components
|
|
21
|
+
<code>src/components/</code> — pages and components
|
|
37
22
|
</li>
|
|
38
23
|
<li>
|
|
39
24
|
<code>rshono.config.ts</code> — deploy target, security and build settings
|
|
@@ -41,9 +26,7 @@ export default function Home({ url, ctx }: PageProps<'/', AppEnv>) {
|
|
|
41
26
|
</ul>
|
|
42
27
|
|
|
43
28
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
|
44
|
-
|
|
45
|
-
prop, so reading it needs no import. It is server-only and never crosses into a client component. */}
|
|
46
|
-
Rendered on the server for <code>{url.pathname}</code>, request <code>{ctx.var.requestId}</code>.
|
|
29
|
+
Rendered on the server for <code>{url.pathname}</code>.
|
|
47
30
|
</p>
|
|
48
31
|
</Layout>
|
|
49
32
|
);
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import { publicEnv } from '../lib/env';
|
|
3
2
|
import '../styles.css';
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
* in. Importing the stylesheet here is what attaches it to each of those pages.
|
|
8
|
-
*/
|
|
4
|
+
export const appName = process.env.PUBLIC_APP_NAME ?? '{{PROJECT_NAME}}';
|
|
5
|
+
|
|
9
6
|
export function Layout({ title, description, children }: { title?: string; description?: string; children: ReactNode }) {
|
|
10
|
-
const heading = title ? `${title} · ${
|
|
7
|
+
const heading = title ? `${title} · ${appName}` : appName;
|
|
11
8
|
|
|
12
9
|
return (
|
|
13
10
|
<html lang="en">
|
|
@@ -22,9 +19,8 @@ export function Layout({ title, description, children }: { title?: string; descr
|
|
|
22
19
|
<header className="mx-auto max-w-2xl px-6 py-5">
|
|
23
20
|
<nav className="flex items-center justify-between gap-4">
|
|
24
21
|
<a href="/" className="font-semibold no-underline">
|
|
25
|
-
{
|
|
22
|
+
{appName}
|
|
26
23
|
</a>
|
|
27
|
-
{/* `data-native` opts a link out of soft navigation and does a full browser load. */}
|
|
28
24
|
<a href="/api/health" data-native className="text-sm">
|
|
29
25
|
/api/health
|
|
30
26
|
</a>
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
@import 'tailwindcss';
|
|
2
2
|
|
|
3
|
-
/*
|
|
4
|
-
* Element defaults for the parts of the app whose markup carries no classes — the form in
|
|
5
|
-
* `components/greet-form.tsx`, and code and links wherever they appear. Everything else is styled with
|
|
6
|
-
* utilities in the markup; see `components/layout.tsx` and `components/home.tsx`.
|
|
7
|
-
*/
|
|
8
3
|
@layer base {
|
|
9
4
|
a {
|
|
10
5
|
@apply text-blue-600 underline decoration-1 underline-offset-2 dark:text-blue-400;
|
|
@@ -17,24 +12,4 @@
|
|
|
17
12
|
pre {
|
|
18
13
|
@apply overflow-x-auto rounded-lg border border-zinc-200 bg-white p-4 font-mono text-xs dark:border-zinc-800 dark:bg-zinc-900;
|
|
19
14
|
}
|
|
20
|
-
|
|
21
|
-
form {
|
|
22
|
-
@apply flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
label {
|
|
26
|
-
@apply basis-full text-sm text-zinc-500 dark:text-zinc-400;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
input {
|
|
30
|
-
@apply flex-1 basis-48 rounded-md border border-zinc-200 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-950;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
button {
|
|
34
|
-
@apply cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white disabled:cursor-progress disabled:opacity-60;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
output {
|
|
38
|
-
@apply basis-full text-zinc-500 dark:text-zinc-400;
|
|
39
|
-
}
|
|
40
15
|
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
'use server';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* A server action: an ordinary async function the browser can call. React hands the client an id for
|
|
5
|
-
* this export and posts to it, so **every `'use server'` export is a public HTTP endpoint** —
|
|
6
|
-
* authenticate, authorize and validate the arguments exactly as you would in a route handler. The
|
|
7
|
-
* framework's CSRF check proves a request came from your own site; it says nothing about who sent it.
|
|
8
|
-
*
|
|
9
|
-
* The signature is the one `useActionState` expects: previous state first, then the form data.
|
|
10
|
-
*/
|
|
11
|
-
export async function greet(_previous: string | null, formData: FormData): Promise<string> {
|
|
12
|
-
// A form field is a string or a File, never only a string — a file input posted under this name would
|
|
13
|
-
// stringify to "[object File]" rather than fail. Narrowing is the validation the endpoint owes itself.
|
|
14
|
-
const field = formData.get('name');
|
|
15
|
-
const name = typeof field === 'string' ? field.trim() : '';
|
|
16
|
-
if (!name) return 'Type a name first.';
|
|
17
|
-
|
|
18
|
-
// Where real work goes — a database write, an email, a queue push.
|
|
19
|
-
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
20
|
-
|
|
21
|
-
return `Hello, ${name}. This ran on the server.`;
|
|
22
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useActionState } from 'react';
|
|
4
|
-
import { greet } from '../actions';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The client half. `'use client'` is the boundary: this module ships to the browser and hydrates, while
|
|
8
|
-
* everything that only renders it stays on the server.
|
|
9
|
-
*
|
|
10
|
-
* Wiring the action to `<form action>` means it works before hydration and with JavaScript switched off
|
|
11
|
-
* — the browser posts the form, the server runs the action and answers with a fresh page. That is what
|
|
12
|
-
* progressive enhancement buys, and it costs nothing here.
|
|
13
|
-
*/
|
|
14
|
-
export function GreetForm() {
|
|
15
|
-
const [message, action, pending] = useActionState(greet, null);
|
|
16
|
-
|
|
17
|
-
return (
|
|
18
|
-
<form action={action}>
|
|
19
|
-
<label htmlFor="name">Your name</label>
|
|
20
|
-
<input id="name" name="name" placeholder="Ada" autoComplete="off" />
|
|
21
|
-
<button type="submit" disabled={pending}>
|
|
22
|
-
{pending ? 'Saying hello…' : 'Say hello'}
|
|
23
|
-
</button>
|
|
24
|
-
{message && <output>{message}</output>}
|
|
25
|
-
</form>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Environment access in one place, on the right side of the client/server line.
|
|
3
|
-
*
|
|
4
|
-
* The boundary is the RSC directives, not filenames. In a `'use client'` module — and in its SSR pass —
|
|
5
|
-
* `process.env` is replaced at build time with a literal holding `NODE_ENV` and the `PUBLIC_`-prefixed
|
|
6
|
-
* variables only, so a secret read there compiles to `undefined` and cannot ship. Server components and
|
|
7
|
-
* `'use server'` actions read the real environment.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/** Safe anywhere: `PUBLIC_` variables are the ones compiled into the browser bundle. */
|
|
11
|
-
export const publicEnv = {
|
|
12
|
-
appName: process.env.PUBLIC_APP_NAME ?? '{{PROJECT_NAME}}',
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Server-only. Throws rather than handing back `undefined`, so a missing secret fails at the point of
|
|
17
|
-
* use with a name in the message instead of turning into a confusing error further down.
|
|
18
|
-
*
|
|
19
|
-
* Calling this from a client component would always throw — that view of `process.env` holds nothing but
|
|
20
|
-
* `NODE_ENV` and the `PUBLIC_` set. Read secrets in server code and pass derived values down as props.
|
|
21
|
-
*/
|
|
22
|
-
export function requireEnv(name: string): string {
|
|
23
|
-
const value = process.env[name];
|
|
24
|
-
if (!value) throw new Error(`Missing environment variable ${name} — add it to .env.local`);
|
|
25
|
-
return value;
|
|
26
|
-
}
|