odoro 1.0.9 → 2.1.0
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 +94 -0
- package/client.d.ts +90 -16
- package/dist/build-XJIP4GNT.js +5 -0
- package/dist/{chunk-34RGOZFA.js → chunk-4D56Z7G3.js} +49 -50
- package/dist/{chunk-JMEHF3KN.js → chunk-7ZB5MAM6.js} +4 -4
- package/dist/chunk-DF67LADH.js +735 -0
- package/dist/{chunk-LHZGX5ML.js → chunk-HS7NGZLM.js} +307 -196
- package/dist/chunk-LIKVHWB4.js +231 -0
- package/dist/chunk-QOVBLN7A.js +241 -0
- package/dist/chunk-RTWJ5GFB.js +729 -0
- package/dist/chunk-TONYIKCN.js +57 -0
- package/dist/cli.d.ts +12 -13
- package/dist/cli.js +86 -63
- package/dist/{commands-AHMXBWQQ.js → commands-GYA7NTCX.js} +124 -124
- package/dist/commands-UOWN3IK4.js +181 -0
- package/dist/{create-CYVSVDAD.js → create-B2TLV6XI.js} +198 -160
- package/dist/index.d.ts +603 -151
- package/dist/index.js +6 -6
- package/dist/{package-EIDLKAA6.js → package-3ZJOEXM4.js} +6 -5
- package/dist/preview-3IRG7MHV.js +4 -0
- package/dist/registry/index.d.ts +75 -76
- package/dist/registry/index.js +1 -1
- package/dist/{server-4UGN3SFS.js → server-ZB3P3EGS.js} +3 -3
- package/package.json +6 -5
- package/templates/react-ts/README.md +71 -35
- package/templates/react-ts/_env.example +9 -0
- package/templates/react-ts/_variants/with-engine/src/background.tsx +118 -0
- package/templates/react-ts/_variants/without-libs/src/App.tsx +380 -0
- package/templates/react-ts/_variants/without-libs/src/background.tsx +24 -0
- package/templates/react-ts/_variants/without-libs/src/entry-server.tsx +39 -0
- package/templates/react-ts/_variants/without-libs/src/main.tsx +26 -0
- package/templates/{react-ts-server/_variantes/sans-libs/client → react-ts/_variants/without-libs}/src/styles.css +137 -137
- package/templates/react-ts/{_variantes/sans-routeur → _variants/without-router}/src/App.tsx +141 -141
- package/templates/react-ts/_variants/without-router/src/entry-server.tsx +39 -0
- package/templates/react-ts/index.html +4 -4
- package/templates/react-ts/odoro.config.ts +5 -0
- package/templates/react-ts/src/App.tsx +219 -209
- package/templates/react-ts/src/background.tsx +66 -0
- package/templates/react-ts/src/entry-server.tsx +85 -0
- package/templates/react-ts/src/main.tsx +13 -4
- package/templates/react-ts/src/router.tsx +65 -45
- package/templates/react-ts/src/styles.css +5 -5
- package/templates/react-ts-server/Dockerfile +9 -9
- package/templates/react-ts-server/README.md +97 -45
- package/templates/react-ts-server/_env.example +31 -33
- package/templates/react-ts-server/_variants/with-engine/client/src/background.tsx +118 -0
- package/templates/react-ts-server/_variants/without-libs/client/src/App.tsx +380 -0
- package/templates/react-ts-server/_variants/without-libs/client/src/background.tsx +24 -0
- package/templates/react-ts-server/_variants/without-libs/client/src/entry-server.tsx +39 -0
- package/templates/react-ts-server/_variants/without-libs/client/src/main.tsx +26 -0
- package/templates/{react-ts/_variantes/sans-libs → react-ts-server/_variants/without-libs/client}/src/styles.css +137 -137
- package/templates/react-ts-server/{_variantes/sans-routeur → _variants/without-router}/client/src/App.tsx +141 -141
- package/templates/react-ts-server/_variants/without-router/client/src/entry-server.tsx +39 -0
- package/templates/react-ts-server/client/index.html +4 -4
- package/templates/react-ts-server/client/src/App.tsx +228 -210
- package/templates/react-ts-server/client/src/account.tsx +267 -0
- package/templates/react-ts-server/client/src/auth.tsx +139 -0
- package/templates/react-ts-server/client/src/background.tsx +66 -0
- package/templates/react-ts-server/client/src/entry-server.tsx +92 -0
- package/templates/react-ts-server/client/src/main.tsx +17 -5
- package/templates/react-ts-server/client/src/router.tsx +77 -45
- package/templates/react-ts-server/client/src/styles.css +5 -5
- package/templates/react-ts-server/odoro.config.ts +7 -4
- package/templates/react-ts-server/package.json +2 -0
- package/templates/react-ts-server/scripts/dev.mjs +14 -14
- package/templates/react-ts-server/server/src/main.ts +119 -45
- package/templates/react-ts-server/server/src/modules/auth/index.ts +238 -0
- package/templates/react-ts-server/server/src/modules/auth/password.ts +122 -0
- package/templates/react-ts-server/server/src/modules/auth/store.ts +193 -0
- package/templates/react-ts-server/server/src/modules/health/index.ts +45 -46
- package/dist/build-JFQHODAT.js +0 -5
- package/dist/chunk-22KJTV2R.js +0 -380
- package/dist/chunk-3SZIN6VG.js +0 -64
- package/dist/chunk-DL3NPC4H.js +0 -113
- package/dist/chunk-TQUJ3MFS.js +0 -268
- package/dist/chunk-ZVL7EXJO.js +0 -66
- package/dist/commands-4JRBD55Z.js +0 -245
- package/dist/preview-7DKEAQOQ.js +0 -4
- package/templates/react-ts/_variantes/avec-moteur/src/fond.tsx +0 -118
- package/templates/react-ts/_variantes/sans-libs/src/App.tsx +0 -382
- package/templates/react-ts/_variantes/sans-libs/src/fond.tsx +0 -23
- package/templates/react-ts/_variantes/sans-libs/src/main.tsx +0 -17
- package/templates/react-ts/src/fond.tsx +0 -66
- package/templates/react-ts-server/_variantes/avec-moteur/client/src/fond.tsx +0 -118
- package/templates/react-ts-server/_variantes/sans-libs/client/src/App.tsx +0 -382
- package/templates/react-ts-server/_variantes/sans-libs/client/src/fond.tsx +0 -23
- package/templates/react-ts-server/_variantes/sans-libs/client/src/main.tsx +0 -17
- package/templates/react-ts-server/client/src/fond.tsx +0 -66
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decorative background of the page.
|
|
3
|
+
*
|
|
4
|
+
* ## Why it is not in `App.tsx`
|
|
5
|
+
*
|
|
6
|
+
* It is the only piece of the page that depends on what was ticked at
|
|
7
|
+
* creation: with the engine, this file is replaced by a version that opens an
|
|
8
|
+
* animated WebGL surface; without it, it is the gradient below.
|
|
9
|
+
*
|
|
10
|
+
* Keeping it apart avoids having two `App.tsx` files to maintain — one per
|
|
11
|
+
* case — which would diverge at the first change of wording. `App.tsx` places
|
|
12
|
+
* `<Background />` and asks for nothing more.
|
|
13
|
+
*
|
|
14
|
+
* ## Why the sizes are inline styles
|
|
15
|
+
*
|
|
16
|
+
* The style system **emits no arbitrary-value class**: `o-h-[42rem]` produces
|
|
17
|
+
* no rule at all, and a missing class paints nothing. The container ended up
|
|
18
|
+
* zero pixels tall, so did both glows, and the background was nowhere to be
|
|
19
|
+
* seen — with nothing to report it.
|
|
20
|
+
*
|
|
21
|
+
* The utilities cover the values on the scale; anything off the scale is
|
|
22
|
+
* written as a style, where it is safe.
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ReactElement } from 'react'
|
|
28
|
+
|
|
29
|
+
/** Two brand glows, heavily diluted, behind the top of the page. */
|
|
30
|
+
export function Background(): ReactElement {
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
aria-hidden="true"
|
|
34
|
+
className="o-pointer-events-none o-absolute o-inset-x-0 o-top-0 o-overflow-hidden"
|
|
35
|
+
style={{ height: '42rem' }}
|
|
36
|
+
>
|
|
37
|
+
<div
|
|
38
|
+
className="o-absolute o-rounded-full"
|
|
39
|
+
style={{
|
|
40
|
+
left: '50%',
|
|
41
|
+
top: '-18rem',
|
|
42
|
+
width: '46rem',
|
|
43
|
+
height: '46rem',
|
|
44
|
+
transform: 'translateX(-50%)',
|
|
45
|
+
filter: 'blur(64px)',
|
|
46
|
+
opacity: 0.3,
|
|
47
|
+
background:
|
|
48
|
+
'radial-gradient(closest-side, var(--o-palette-brand-500), transparent)',
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
<div
|
|
52
|
+
className="o-absolute o-rounded-full"
|
|
53
|
+
style={{
|
|
54
|
+
right: '-10rem',
|
|
55
|
+
top: '6rem',
|
|
56
|
+
width: '30rem',
|
|
57
|
+
height: '30rem',
|
|
58
|
+
filter: 'blur(64px)',
|
|
59
|
+
opacity: 0.18,
|
|
60
|
+
background:
|
|
61
|
+
'radial-gradient(closest-side, var(--o-palette-brand-400), transparent)',
|
|
62
|
+
}}
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The prerendering entry point.
|
|
3
|
+
*
|
|
4
|
+
* ## What it does, and why it exists
|
|
5
|
+
*
|
|
6
|
+
* `odoro build` renders each of the routes below to HTML, at build time, and
|
|
7
|
+
* writes one complete document per route. The site stays a set of static files
|
|
8
|
+
* — nothing runs in production — but a search engine, a link preview or a
|
|
9
|
+
* screen reader find text in the very first response, instead of an empty
|
|
10
|
+
* container.
|
|
11
|
+
*
|
|
12
|
+
* `src/main.tsx` then takes over: seeing a container already filled, it
|
|
13
|
+
* hydrates instead of rebuilding.
|
|
14
|
+
*
|
|
15
|
+
* ## Adding a route
|
|
16
|
+
*
|
|
17
|
+
* A single line in `routes`. The path becomes a directory in `dist/`:
|
|
18
|
+
* `/pricing` gives `dist/pricing/index.html`, which any host serves with no
|
|
19
|
+
* configuration.
|
|
20
|
+
*
|
|
21
|
+
* ## Doing without
|
|
22
|
+
*
|
|
23
|
+
* Remove `prerender` from `odoro.config.ts`. This file can then go: nothing
|
|
24
|
+
* else imports it.
|
|
25
|
+
*
|
|
26
|
+
* @module
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { StrictMode } from 'react'
|
|
30
|
+
import { renderToString } from 'react-dom/server'
|
|
31
|
+
|
|
32
|
+
import { App } from '@/App'
|
|
33
|
+
|
|
34
|
+
/** The routes rendered at build time. */
|
|
35
|
+
export const routes = ['/', '/about']
|
|
36
|
+
|
|
37
|
+
/** What a page brings to the head of the document. */
|
|
38
|
+
const TITLES: Readonly<Record<string, { title: string; description: string }>> = {
|
|
39
|
+
'/': {
|
|
40
|
+
title: 'Odoro',
|
|
41
|
+
description: 'An Odoro project: in-house engine, generated styles, built-in router.',
|
|
42
|
+
},
|
|
43
|
+
'/about': {
|
|
44
|
+
title: 'About — Odoro',
|
|
45
|
+
description: 'What this project ships, and what each package does in it.',
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Escapes what goes into an HTML attribute. */
|
|
50
|
+
function escape(text: string): string {
|
|
51
|
+
return text
|
|
52
|
+
.replace(/&/g, '&')
|
|
53
|
+
.replace(/</g, '<')
|
|
54
|
+
.replace(/>/g, '>')
|
|
55
|
+
.replace(/"/g, '"')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Renders a route.
|
|
60
|
+
*
|
|
61
|
+
* @param url The requested path.
|
|
62
|
+
* @returns The HTML of the container, and the tags to add to the head.
|
|
63
|
+
*/
|
|
64
|
+
export function render(url: string): { html: string; head: string } {
|
|
65
|
+
const meta = TITLES[url] ?? TITLES['/']
|
|
66
|
+
|
|
67
|
+
const head = [
|
|
68
|
+
`<title>${escape(meta?.title ?? 'Odoro')}</title>`,
|
|
69
|
+
`<meta name="description" content="${escape(meta?.description ?? '')}">`,
|
|
70
|
+
`<meta property="og:title" content="${escape(meta?.title ?? 'Odoro')}">`,
|
|
71
|
+
`<meta property="og:description" content="${escape(meta?.description ?? '')}">`,
|
|
72
|
+
].join('\n ')
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
// `StrictMode` is present on both sides: a tree rendered without it here
|
|
76
|
+
// and with it in the browser does not always produce the same markup, and
|
|
77
|
+
// hydration then reports a difference that is not one.
|
|
78
|
+
html: renderToString(
|
|
79
|
+
<StrictMode>
|
|
80
|
+
<App url={url} />
|
|
81
|
+
</StrictMode>,
|
|
82
|
+
),
|
|
83
|
+
head,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { StrictMode } from 'react'
|
|
2
|
-
import { createRoot } from 'react-dom/client'
|
|
2
|
+
import { createRoot, hydrateRoot } from 'react-dom/client'
|
|
3
3
|
|
|
4
4
|
import { App } from '@/App'
|
|
5
5
|
|
|
@@ -8,11 +8,20 @@ import '@/styles.css'
|
|
|
8
8
|
|
|
9
9
|
const container = document.getElementById('root')
|
|
10
10
|
if (container === null) {
|
|
11
|
-
throw new Error('
|
|
11
|
+
throw new Error('Root element "#root" not found in index.html.')
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
const tree = (
|
|
15
15
|
<StrictMode>
|
|
16
16
|
<App />
|
|
17
|
-
</StrictMode
|
|
17
|
+
</StrictMode>
|
|
18
18
|
)
|
|
19
|
+
|
|
20
|
+
// A container that is already filled comes from prerendering: it has to be
|
|
21
|
+
// **hydrated**, that is, taken over as it stands with the events attached.
|
|
22
|
+
// Rebuilding it would throw away the page the visitor already sees only to draw
|
|
23
|
+
// it again identically — a flicker, and the whole benefit of prerendering lost.
|
|
24
|
+
//
|
|
25
|
+
// Empty, it is an ordinary render.
|
|
26
|
+
if (container.firstElementChild === null) createRoot(container).render(tree)
|
|
27
|
+
else hydrateRoot(container, tree)
|
|
@@ -1,72 +1,92 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The router of the project.
|
|
3
3
|
*
|
|
4
|
-
* ##
|
|
4
|
+
* ## Why it lives alone, in its own file
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* This is the only place that names the routing dependency. Switching routers,
|
|
7
|
+
* adding a route, putting one behind authentication: it all reads here, and
|
|
8
|
+
* `App.tsx` needs to know nothing beyond the line that imports it.
|
|
9
9
|
*
|
|
10
|
-
* ##
|
|
10
|
+
* ## Why the pages are passed in, and not imported
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* jour ou quelque chose s'execute au chargement du module.
|
|
12
|
+
* This file could import the pages from `App.tsx`. The two modules would then
|
|
13
|
+
* import each other: that works, but evaluation order becomes a question
|
|
14
|
+
* nobody wants to answer the day something runs at module load.
|
|
16
15
|
*
|
|
17
|
-
*
|
|
18
|
-
* `App.tsx`
|
|
19
|
-
*
|
|
16
|
+
* So the pages arrive as properties. The dependency only goes one way —
|
|
17
|
+
* `App.tsx` knows the router, the router only knows React — and the route
|
|
18
|
+
* table stays readable at a glance.
|
|
20
19
|
*
|
|
21
20
|
* @module
|
|
22
21
|
*/
|
|
23
22
|
|
|
24
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
Outlet,
|
|
25
|
+
Route,
|
|
26
|
+
// Aliased: the component exported below is the one the application sees, and
|
|
27
|
+
// it takes the name `Router`.
|
|
28
|
+
Router as RouterProvider,
|
|
29
|
+
Routes,
|
|
30
|
+
createMemoryHistory,
|
|
31
|
+
} from '@odoro-cli/libs/router'
|
|
25
32
|
import type { ReactElement, ReactNode } from 'react'
|
|
26
33
|
|
|
27
34
|
export { Link, useLocation } from '@odoro-cli/libs/router'
|
|
28
35
|
|
|
29
|
-
/**
|
|
30
|
-
export interface
|
|
31
|
-
/**
|
|
32
|
-
readonly
|
|
33
|
-
/**
|
|
34
|
-
readonly
|
|
35
|
-
/**
|
|
36
|
-
readonly
|
|
37
|
-
/**
|
|
38
|
-
readonly
|
|
36
|
+
/** The pages the router places. */
|
|
37
|
+
export interface RouterProps {
|
|
38
|
+
/** The common shell: navigation, content, footer. */
|
|
39
|
+
readonly shell: (content: ReactNode) => ReactElement
|
|
40
|
+
/** The home page. */
|
|
41
|
+
readonly home: ReactElement
|
|
42
|
+
/** The "About" page. */
|
|
43
|
+
readonly about: ReactElement
|
|
44
|
+
/** What shows when no route matches. */
|
|
45
|
+
readonly notFound: ReactElement
|
|
46
|
+
/**
|
|
47
|
+
* The address to render, when there is no address bar.
|
|
48
|
+
*
|
|
49
|
+
* That is the case while prerendering: the page is made on the build
|
|
50
|
+
* machine, where `window` does not exist and nothing says which route is
|
|
51
|
+
* being asked for. The in-memory history takes over then.
|
|
52
|
+
*
|
|
53
|
+
* In the browser it stays absent and the router reads the real address.
|
|
54
|
+
*/
|
|
55
|
+
readonly url?: string
|
|
39
56
|
}
|
|
40
57
|
|
|
41
58
|
/**
|
|
42
|
-
*
|
|
59
|
+
* The route table.
|
|
43
60
|
*
|
|
44
61
|
* @example
|
|
45
|
-
* <
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
62
|
+
* <Router
|
|
63
|
+
* shell={(content) => <Shell>{content}</Shell>}
|
|
64
|
+
* home={<Home />}
|
|
65
|
+
* about={<About />}
|
|
66
|
+
* notFound={<NotFound />}
|
|
50
67
|
* />
|
|
51
68
|
*/
|
|
52
|
-
export function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
export function Router({
|
|
70
|
+
shell,
|
|
71
|
+
home,
|
|
72
|
+
about,
|
|
73
|
+
notFound,
|
|
74
|
+
url,
|
|
75
|
+
}: RouterProps): ReactElement {
|
|
58
76
|
return (
|
|
59
|
-
<
|
|
77
|
+
<RouterProvider
|
|
78
|
+
history={url === undefined ? undefined : createMemoryHistory([url])}
|
|
79
|
+
>
|
|
60
80
|
<Routes>
|
|
61
|
-
{/* `Outlet`
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
<Route path="/" element={
|
|
65
|
-
<Route index element={
|
|
66
|
-
<Route path="
|
|
67
|
-
<Route path="*" element={
|
|
81
|
+
{/* `Outlet` marks the place where the current page renders: that is
|
|
82
|
+
what keeps the navigation and the footer from being remounted on
|
|
83
|
+
every route change. */}
|
|
84
|
+
<Route path="/" element={shell(<Outlet />)}>
|
|
85
|
+
<Route index element={home} />
|
|
86
|
+
<Route path="about" element={about} />
|
|
87
|
+
<Route path="*" element={notFound} />
|
|
68
88
|
</Route>
|
|
69
89
|
</Routes>
|
|
70
|
-
</
|
|
90
|
+
</RouterProvider>
|
|
71
91
|
)
|
|
72
92
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
/* Styles
|
|
1
|
+
/* Styles that belong to the application.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
page,
|
|
3
|
+
Everything else comes from the Odoro tokens: overriding a variable below
|
|
4
|
+
rethemes the whole thing, library components included. The brand hue is
|
|
5
|
+
`--o-palette-brand-500`; replacing it is enough to change the colour of the
|
|
6
|
+
page, of the mark and of the buttons in one go. */
|
|
7
7
|
|
|
8
8
|
.app-shell {
|
|
9
9
|
min-height: 100dvh;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# syntax=docker/dockerfile:1
|
|
2
2
|
|
|
3
|
-
# ---
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# --- Stage 1: dependencies -----------------------------------------------
|
|
4
|
+
# Isolated so that the layer cache survives any change to the code: only a
|
|
5
|
+
# change to the manifest re-runs the install.
|
|
6
6
|
FROM node:22-alpine AS deps
|
|
7
7
|
WORKDIR /app
|
|
8
8
|
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
|
|
@@ -14,28 +14,28 @@ RUN if [ -f pnpm-lock.yaml ]; then \
|
|
|
14
14
|
npm ci; \
|
|
15
15
|
fi
|
|
16
16
|
|
|
17
|
-
# ---
|
|
17
|
+
# --- Stage 2: build -------------------------------------------------------
|
|
18
18
|
FROM node:22-alpine AS build
|
|
19
19
|
WORKDIR /app
|
|
20
20
|
COPY --from=deps /app/node_modules ./node_modules
|
|
21
21
|
COPY . .
|
|
22
22
|
RUN npm run build
|
|
23
23
|
|
|
24
|
-
# ---
|
|
25
|
-
#
|
|
26
|
-
#
|
|
24
|
+
# --- Stage 3: production dependencies -------------------------------------
|
|
25
|
+
# Reinstalled separately: the final image must not carry the build chain,
|
|
26
|
+
# which often weighs more than the application itself.
|
|
27
27
|
FROM node:22-alpine AS runtime-deps
|
|
28
28
|
WORKDIR /app
|
|
29
29
|
COPY package.json package-lock.json* ./
|
|
30
30
|
RUN npm ci --omit=dev || npm install --omit=dev
|
|
31
31
|
|
|
32
|
-
# ---
|
|
32
|
+
# --- Stage 4: final image -------------------------------------------------
|
|
33
33
|
FROM node:22-alpine AS runtime
|
|
34
34
|
WORKDIR /app
|
|
35
35
|
ENV NODE_ENV=production
|
|
36
36
|
ENV PORT=3001
|
|
37
37
|
|
|
38
|
-
#
|
|
38
|
+
# An application process has no reason to run as root.
|
|
39
39
|
RUN addgroup -S odoro && adduser -S odoro -G odoro
|
|
40
40
|
|
|
41
41
|
COPY --from=runtime-deps --chown=odoro:odoro /app/node_modules ./node_modules
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Odoro application — client and server
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
A React single-page application and an API built on `@odoro-cli/server`, in the
|
|
4
|
+
same repository.
|
|
5
5
|
|
|
6
6
|
```
|
|
7
|
-
client/ interface,
|
|
7
|
+
client/ interface, served by Odoro in development
|
|
8
8
|
server/
|
|
9
|
-
src/main.ts
|
|
10
|
-
src/modules/
|
|
9
|
+
src/main.ts assembles the modules, nothing else
|
|
10
|
+
src/modules/ one directory per module — start with `health/`
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## Getting started
|
|
14
14
|
|
|
15
15
|
```sh
|
|
16
16
|
cp .env.example .env
|
|
@@ -18,30 +18,58 @@ npm install
|
|
|
18
18
|
npm run dev
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
The client listens on <http://localhost:5180> and forwards every call starting
|
|
22
|
+
with `/api` to the server. The browser therefore only ever sees one origin, and
|
|
23
|
+
no CORS question arises in development.
|
|
24
24
|
|
|
25
|
-
##
|
|
25
|
+
## Authentication, as a demonstration
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
The template ships a working account system: register, sign in, sign out,
|
|
28
|
+
profile. Four routes under `/api/auth/`, three pages at `/sign-in`, `/register`
|
|
29
|
+
and `/profile`.
|
|
30
|
+
|
|
31
|
+
What it does, and how:
|
|
32
|
+
|
|
33
|
+
- the password is hashed with **scrypt**, from the Node standard library — no
|
|
34
|
+
native module to compile. The cost parameters travel with the hash, so the
|
|
35
|
+
passwords already stored still verify the day the cost is raised;
|
|
36
|
+
- the session lives in an **`httpOnly`** cookie: the browser sends it, no
|
|
37
|
+
script reads it. The table keeps only its fingerprint, never the identifier;
|
|
38
|
+
- an unknown address and a wrong password give **the same answer**, after the
|
|
39
|
+
same delay. Telling them apart would turn the sign-in form into a way to ask
|
|
40
|
+
whether an address has an account here.
|
|
41
|
+
|
|
42
|
+
What it does not do, and what you will have to add: email confirmation,
|
|
43
|
+
password reset, a rate limit on sign-in attempts, a second factor. Naming them
|
|
44
|
+
beats implying they are handled.
|
|
45
|
+
|
|
46
|
+
It all lives in `server/src/modules/auth/` and `client/src/account.tsx`. If the
|
|
47
|
+
project needs no accounts, delete those two and the `auth.module` line of
|
|
48
|
+
`server/src/main.ts`.
|
|
49
|
+
|
|
50
|
+
**A database is required.** Without `DATABASE_URL`, the four routes answer
|
|
51
|
+
`503` saying what is missing — the interface still starts.
|
|
52
|
+
|
|
53
|
+
## The database
|
|
54
|
+
|
|
55
|
+
**PostgreSQL, hosted, and nothing else.** There is no local database: the URL
|
|
56
|
+
points at one reachable over the network.
|
|
29
57
|
|
|
30
58
|
```sh
|
|
31
|
-
odoro db:create #
|
|
32
|
-
#
|
|
33
|
-
DATABASE_URL=postgres://
|
|
59
|
+
odoro db:create # provisions a database and writes .env
|
|
60
|
+
# or paste your own URL into .env:
|
|
61
|
+
DATABASE_URL=postgres://user:password@host:5432/database?sslmode=require
|
|
34
62
|
```
|
|
35
63
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
64
|
+
While it is missing, **the client starts anyway** and the server answers `503`
|
|
65
|
+
on `/api/ready` saying precisely what is absent. So you see the interface in
|
|
66
|
+
the first minute, and you know what is left to do.
|
|
39
67
|
|
|
40
|
-
##
|
|
68
|
+
## Writing a module
|
|
41
69
|
|
|
42
|
-
`server/src/modules/health/`
|
|
43
|
-
|
|
44
|
-
|
|
70
|
+
`server/src/modules/health/` is the example. A module declares its name, what
|
|
71
|
+
it needs, the services it registers and the routes it exposes — and `main.ts`
|
|
72
|
+
does nothing but say which ones are active.
|
|
45
73
|
|
|
46
74
|
```ts
|
|
47
75
|
export const billingModule = defineModule({
|
|
@@ -52,36 +80,60 @@ export const billingModule = defineModule({
|
|
|
52
80
|
})
|
|
53
81
|
```
|
|
54
82
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
83
|
+
The kernel resolves the load order from `requires`, detects cycles, and refuses
|
|
84
|
+
to start if a dependency is missing. Enabling or disabling a module therefore
|
|
85
|
+
takes one line in `main.ts`.
|
|
58
86
|
|
|
59
|
-
##
|
|
87
|
+
## Two health endpoints, which do not say the same thing
|
|
60
88
|
|
|
61
|
-
| Route | Question
|
|
62
|
-
| ------------- |
|
|
63
|
-
| `/api/health` |
|
|
64
|
-
| `/api/ready` |
|
|
89
|
+
| Route | Question | Who asks it |
|
|
90
|
+
| ------------- | ----------------------------- | --------------------------------------- |
|
|
91
|
+
| `/api/health` | Is the process alive? | The orchestrator, to decide on a restart |
|
|
92
|
+
| `/api/ready` | Can the service do its work? | The load balancer, to decide on traffic |
|
|
65
93
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
94
|
+
Confusing them gives one of two faults: a service restarting in a loop during a
|
|
95
|
+
database incident, or a load balancer sending traffic to a service that cannot
|
|
96
|
+
answer.
|
|
69
97
|
|
|
70
|
-
##
|
|
98
|
+
## Building and deploying
|
|
71
99
|
|
|
72
100
|
```sh
|
|
73
|
-
npm run build # client
|
|
74
|
-
npm start #
|
|
101
|
+
npm run build # client into dist/client, server into dist/server
|
|
102
|
+
npm start # serves both from a single process
|
|
75
103
|
```
|
|
76
104
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
105
|
+
The `Dockerfile` is multi-stage: the build dependencies do not end up in the
|
|
106
|
+
final image, which runs as an unprivileged user.
|
|
107
|
+
|
|
108
|
+
## Prerendering
|
|
109
|
+
|
|
110
|
+
`build.prerender` is on: every route of `client/src/entry-server.tsx` is
|
|
111
|
+
rendered as a complete HTML document at build time, and the client hydrates it
|
|
112
|
+
on load rather than rebuilding everything. That is what lets a search engine or
|
|
113
|
+
a link preview find text in the very first response.
|
|
114
|
+
|
|
115
|
+
The server serves those documents before falling back to the single-page
|
|
116
|
+
document: a request for `/about` receives `dist/client/about/index.html`, with
|
|
117
|
+
its own title and its own description.
|
|
118
|
+
|
|
119
|
+
Adding a route: one line in `routes`, and the matching entry in
|
|
120
|
+
`client/src/router.tsx`. Doing without: remove `prerender` from
|
|
121
|
+
`odoro.config.ts`, then delete `client/src/entry-server.tsx`.
|
|
122
|
+
|
|
123
|
+
## Environment variables
|
|
124
|
+
|
|
125
|
+
See `.env.example`, commented line by line. In production, whatever is missing
|
|
126
|
+
stops the startup with a message that **lists everything at once** — rather
|
|
127
|
+
than a string of restarts, one variable at a time.
|
|
80
128
|
|
|
81
|
-
|
|
129
|
+
The files are read from the least specific to the most specific — `.env`,
|
|
130
|
+
`.env.local`, `.env.<mode>`, `.env.<mode>.local` — and a variable already set
|
|
131
|
+
in the environment is never overwritten by a file: on a host, whatever it
|
|
132
|
+
injects wins.
|
|
82
133
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
134
|
+
**Only variables prefixed with `ODORO_` reach the browser.** The rest —
|
|
135
|
+
`DATABASE_URL`, the session secrets, the API keys — never leaves the server.
|
|
136
|
+
That is the only boundary that matters here, and it is held by the prefix, not
|
|
137
|
+
by the file.
|
|
86
138
|
|
|
87
|
-
`.env`
|
|
139
|
+
`.env` is never committed.
|