create-foldkit-app 0.26.0 → 0.27.1
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 +12 -2
- package/dist/commands/create.js +28 -11
- package/dist/index.js +4 -1
- package/dist/rendering.js +20 -0
- package/dist/utils/files.js +20 -4
- package/dist/utils/packages.js +41 -12
- package/package.json +1 -1
- package/templates/package-managers/pnpm/pnpm-workspace.yaml +1 -0
- package/templates/rendering/ssg/README.md +61 -0
- package/templates/rendering/ssg/package.json +14 -0
- package/templates/rendering/ssg/scripts/build.mjs +40 -0
- package/templates/rendering/ssg/scripts/prerender.ts +71 -0
- package/templates/rendering/ssg/src/entry.server.ts +18 -0
- package/templates/rendering/ssg/src/entry.ts +28 -0
- package/templates/rendering/ssg/src/main.ts +176 -0
- package/templates/rendering/ssg/src/route.ts +20 -0
- package/templates/rendering/ssg/src/scene.test.ts +27 -0
- package/templates/rendering/ssg/src/vite-env.d.ts +19 -0
- package/templates/rendering/ssg/tsconfig.json +16 -0
- package/templates/rendering/ssg/vite.config.ts +17 -0
- package/templates/rendering/ssr/README.md +65 -0
- package/templates/rendering/ssr/package.json +14 -0
- package/templates/rendering/ssr/scripts/build.mjs +36 -0
- package/templates/rendering/ssr/server/main.ts +205 -0
- package/templates/rendering/ssr/src/cookie.ts +13 -0
- package/templates/rendering/ssr/src/entry.server.ts +50 -0
- package/templates/rendering/ssr/src/entry.ts +17 -0
- package/templates/rendering/ssr/src/main.ts +155 -0
- package/templates/rendering/ssr/src/scene.test.ts +43 -0
- package/templates/rendering/ssr/src/vite-env.d.ts +19 -0
- package/templates/rendering/ssr/tsconfig.json +16 -0
- package/templates/rendering/ssr/vite.config.ts +17 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Effect, Match as M, Schema as S, pipe } from 'effect'
|
|
2
|
+
import { Command, Runtime } from 'foldkit'
|
|
3
|
+
import { type Document, type Html, type HtmlBuilder } from 'foldkit/html'
|
|
4
|
+
import { m } from 'foldkit/message'
|
|
5
|
+
import { UrlRequest, load, pushUrl } from 'foldkit/navigation'
|
|
6
|
+
import { evo } from 'foldkit/struct'
|
|
7
|
+
import { Url, toString as urlToString } from 'foldkit/url'
|
|
8
|
+
|
|
9
|
+
import { AppRoute, aboutRouter, homeRouter, urlToAppRoute } from './route'
|
|
10
|
+
|
|
11
|
+
// MODEL
|
|
12
|
+
|
|
13
|
+
export const Model = S.Struct({
|
|
14
|
+
route: AppRoute,
|
|
15
|
+
count: S.Number,
|
|
16
|
+
})
|
|
17
|
+
export type Model = typeof Model.Type
|
|
18
|
+
|
|
19
|
+
// MESSAGE
|
|
20
|
+
|
|
21
|
+
export const ClickedIncrement = m('ClickedIncrement')
|
|
22
|
+
export const ClickedLink = m('ClickedLink', { request: UrlRequest })
|
|
23
|
+
export const ChangedUrl = m('ChangedUrl', { url: Url })
|
|
24
|
+
export const CompletedNavigateInternal = m('CompletedNavigateInternal')
|
|
25
|
+
export const CompletedLoadExternal = m('CompletedLoadExternal')
|
|
26
|
+
|
|
27
|
+
export const Message = S.Union([
|
|
28
|
+
ClickedIncrement,
|
|
29
|
+
ClickedLink,
|
|
30
|
+
ChangedUrl,
|
|
31
|
+
CompletedNavigateInternal,
|
|
32
|
+
CompletedLoadExternal,
|
|
33
|
+
])
|
|
34
|
+
export type Message = typeof Message.Type
|
|
35
|
+
|
|
36
|
+
// INIT
|
|
37
|
+
|
|
38
|
+
export const init: Runtime.RoutingApplicationInit<Model, Message> = url => [
|
|
39
|
+
{ route: urlToAppRoute(url), count: 0 },
|
|
40
|
+
[],
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
// COMMAND
|
|
44
|
+
|
|
45
|
+
const NavigateInternal = Command.define('NavigateInternal', {
|
|
46
|
+
args: { url: S.String },
|
|
47
|
+
messages: [CompletedNavigateInternal],
|
|
48
|
+
execute: ({ url }) =>
|
|
49
|
+
pushUrl(url).pipe(Effect.as(CompletedNavigateInternal())),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const LoadExternal = Command.define('LoadExternal', {
|
|
53
|
+
args: { href: S.String },
|
|
54
|
+
messages: [CompletedLoadExternal],
|
|
55
|
+
execute: ({ href }) => load(href).pipe(Effect.as(CompletedLoadExternal())),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// UPDATE
|
|
59
|
+
|
|
60
|
+
type UpdateReturn = readonly [Model, ReadonlyArray<Command.Command<Message>>]
|
|
61
|
+
const withUpdateReturn = M.withReturnType<UpdateReturn>()
|
|
62
|
+
|
|
63
|
+
export const update = (model: Model, message: Message): UpdateReturn =>
|
|
64
|
+
M.value(message).pipe(
|
|
65
|
+
withUpdateReturn,
|
|
66
|
+
M.tagsExhaustive({
|
|
67
|
+
ClickedIncrement: () => [evo(model, { count: count => count + 1 }), []],
|
|
68
|
+
ClickedLink: ({ request }) =>
|
|
69
|
+
M.value(request).pipe(
|
|
70
|
+
withUpdateReturn,
|
|
71
|
+
M.tagsExhaustive({
|
|
72
|
+
Internal: ({ url }) => [
|
|
73
|
+
model,
|
|
74
|
+
[NavigateInternal({ url: urlToString(url) })],
|
|
75
|
+
],
|
|
76
|
+
External: ({ href }) => [model, [LoadExternal({ href })]],
|
|
77
|
+
}),
|
|
78
|
+
),
|
|
79
|
+
ChangedUrl: ({ url }) => [
|
|
80
|
+
evo(model, { route: () => urlToAppRoute(url) }),
|
|
81
|
+
[],
|
|
82
|
+
],
|
|
83
|
+
CompletedNavigateInternal: () => [model, []],
|
|
84
|
+
CompletedLoadExternal: () => [model, []],
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
// VIEW
|
|
89
|
+
|
|
90
|
+
const APP_NAME = 'Foldkit App'
|
|
91
|
+
|
|
92
|
+
const appendAppName = (page: string): string => `${page} | ${APP_NAME}`
|
|
93
|
+
|
|
94
|
+
const routeTitle = (route: AppRoute): string =>
|
|
95
|
+
pipe(
|
|
96
|
+
M.value(route),
|
|
97
|
+
M.tagsExhaustive({
|
|
98
|
+
Home: () => 'Home',
|
|
99
|
+
About: () => 'About',
|
|
100
|
+
NotFound: () => 'Not Found',
|
|
101
|
+
}),
|
|
102
|
+
appendAppName,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const navigationView = (h: HtmlBuilder<Message>): Html =>
|
|
106
|
+
h.nav(
|
|
107
|
+
[h.Class('flex gap-4')],
|
|
108
|
+
[
|
|
109
|
+
h.a([h.Href(homeRouter()), h.Class('underline')], ['Home']),
|
|
110
|
+
h.a([h.Href(aboutRouter()), h.Class('underline')], ['About']),
|
|
111
|
+
],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const pageView = (model: Model, h: HtmlBuilder<Message>): Html =>
|
|
115
|
+
M.value(model.route).pipe(
|
|
116
|
+
M.tagsExhaustive({
|
|
117
|
+
Home: () =>
|
|
118
|
+
h.section(
|
|
119
|
+
[h.Class('grid gap-4')],
|
|
120
|
+
[
|
|
121
|
+
h.h1(
|
|
122
|
+
[h.Id('page-title'), h.Class('text-4xl font-bold')],
|
|
123
|
+
['Statically generated home'],
|
|
124
|
+
),
|
|
125
|
+
h.p(
|
|
126
|
+
[],
|
|
127
|
+
[
|
|
128
|
+
'This route was rendered during the build and hydrated in place.',
|
|
129
|
+
],
|
|
130
|
+
),
|
|
131
|
+
h.button(
|
|
132
|
+
[
|
|
133
|
+
h.OnClick(ClickedIncrement()),
|
|
134
|
+
h.Class('w-fit bg-black px-4 py-2 text-white'),
|
|
135
|
+
],
|
|
136
|
+
[`Count: ${model.count}`],
|
|
137
|
+
),
|
|
138
|
+
],
|
|
139
|
+
),
|
|
140
|
+
About: () =>
|
|
141
|
+
h.section(
|
|
142
|
+
[h.Class('grid gap-4')],
|
|
143
|
+
[
|
|
144
|
+
h.h1(
|
|
145
|
+
[h.Id('page-title'), h.Class('text-4xl font-bold')],
|
|
146
|
+
['Statically generated about page'],
|
|
147
|
+
),
|
|
148
|
+
h.p(
|
|
149
|
+
[],
|
|
150
|
+
[
|
|
151
|
+
'The same renderPage function produced this route in the same build.',
|
|
152
|
+
],
|
|
153
|
+
),
|
|
154
|
+
],
|
|
155
|
+
),
|
|
156
|
+
NotFound: ({ path }) =>
|
|
157
|
+
h.section(
|
|
158
|
+
[h.Class('grid gap-4')],
|
|
159
|
+
[
|
|
160
|
+
h.h1(
|
|
161
|
+
[h.Id('page-title'), h.Class('text-4xl font-bold')],
|
|
162
|
+
['Not found'],
|
|
163
|
+
),
|
|
164
|
+
h.p([], [`No statically generated page exists for ${path}.`]),
|
|
165
|
+
],
|
|
166
|
+
),
|
|
167
|
+
}),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
|
|
171
|
+
title: routeTitle(model.route),
|
|
172
|
+
body: h.main(
|
|
173
|
+
[h.Class('mx-auto grid min-h-screen max-w-3xl content-center gap-10 p-8')],
|
|
174
|
+
[navigationView(h), pageView(model, h)],
|
|
175
|
+
),
|
|
176
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Schema as S, pipe } from 'effect'
|
|
2
|
+
import { Route } from 'foldkit'
|
|
3
|
+
import { literal, r } from 'foldkit/route'
|
|
4
|
+
|
|
5
|
+
export const HomeRoute = r('Home')
|
|
6
|
+
export const AboutRoute = r('About')
|
|
7
|
+
export const NotFoundRoute = r('NotFound', { path: S.String })
|
|
8
|
+
|
|
9
|
+
export const AppRoute = S.Union([HomeRoute, AboutRoute, NotFoundRoute])
|
|
10
|
+
export type AppRoute = typeof AppRoute.Type
|
|
11
|
+
|
|
12
|
+
export const homeRouter = pipe(Route.root, Route.mapTo(HomeRoute))
|
|
13
|
+
export const aboutRouter = pipe(literal('about'), Route.mapTo(AboutRoute))
|
|
14
|
+
|
|
15
|
+
const routeParser = Route.oneOf(aboutRouter, homeRouter)
|
|
16
|
+
|
|
17
|
+
export const urlToAppRoute = Route.parseUrlWithFallback(
|
|
18
|
+
routeParser,
|
|
19
|
+
NotFoundRoute,
|
|
20
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { click, expect, given, role, scene, text } from 'foldkit/scene'
|
|
2
|
+
import { describe, test } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { Model, update, view } from './main'
|
|
5
|
+
import { HomeRoute } from './route'
|
|
6
|
+
|
|
7
|
+
const initialModel = Model.make({ route: HomeRoute(), count: 0 })
|
|
8
|
+
|
|
9
|
+
describe('view', () => {
|
|
10
|
+
test('renders the statically generated home page', () => {
|
|
11
|
+
scene(
|
|
12
|
+
{ update, view },
|
|
13
|
+
given(initialModel),
|
|
14
|
+
expect(text('Statically generated home')).toExist(),
|
|
15
|
+
expect(role('button', { name: 'Count: 0' })).toExist(),
|
|
16
|
+
)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('clicking the counter increments the count', () => {
|
|
20
|
+
scene(
|
|
21
|
+
{ update, view },
|
|
22
|
+
given(initialModel),
|
|
23
|
+
click(role('button', { name: 'Count: 0' })),
|
|
24
|
+
expect(role('button', { name: 'Count: 1' })).toExist(),
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
// `@foldkit/vite-plugin` compiles the deployment's build id in here, from its
|
|
4
|
+
// `buildId` option or the `FOLDKIT_BUILD_ID` environment variable. The server
|
|
5
|
+
// entry hands it to `renderToString` and the client entry to `Runtime.hydrate`,
|
|
6
|
+
// which is how hydration tells a page from this deployment apart from one served
|
|
7
|
+
// by another.
|
|
8
|
+
//
|
|
9
|
+
// Declared as required rather than optional because this project's build always
|
|
10
|
+
// supplies one. A build that did not would produce `undefined` here, and both
|
|
11
|
+
// entries refuse it at runtime; typing it as optional would only push that
|
|
12
|
+
// failure past the compiler and into the served page.
|
|
13
|
+
interface ImportMetaEnv {
|
|
14
|
+
readonly FOLDKIT_BUILD_ID: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ImportMeta {
|
|
18
|
+
readonly env: ImportMetaEnv
|
|
19
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ES2022", "DOM"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"exactOptionalPropertyTypes": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"noEmit": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*", "scripts/**/*"]
|
|
16
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
|
|
3
|
+
import { foldkit } from '@foldkit/vite-plugin'
|
|
4
|
+
import tailwindcss from '@tailwindcss/vite'
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
plugins: [
|
|
8
|
+
tailwindcss(),
|
|
9
|
+
foldkit({
|
|
10
|
+
devToolsMcpPort: 9988,
|
|
11
|
+
ssr: { serverEntry: '/src/entry.server.ts' },
|
|
12
|
+
}),
|
|
13
|
+
],
|
|
14
|
+
optimizeDeps: {
|
|
15
|
+
entries: ['src/entry.ts'],
|
|
16
|
+
},
|
|
17
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# My Foldkit App
|
|
2
|
+
|
|
3
|
+
A server-rendered Foldkit application built with Effect.
|
|
4
|
+
|
|
5
|
+
## Getting Started
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
{{installCommand}}
|
|
9
|
+
{{devCommand}}
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Building and serving
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
{{buildCommand}}
|
|
16
|
+
{{startCommand}}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The build script runs `scripts/build.mjs`, which builds the client bundle and the
|
|
20
|
+
server bundle and gives both the same build id.
|
|
21
|
+
|
|
22
|
+
`PORT` sets the port the server listens on. `ORIGIN` sets the public origin it
|
|
23
|
+
serves, which the server entry sees as `Request.url`; set it when deploying
|
|
24
|
+
behind a proxy or a TLS terminator. It defaults to `http://localhost:<PORT>`.
|
|
25
|
+
|
|
26
|
+
## The build id
|
|
27
|
+
|
|
28
|
+
The build id does not make hydration correct. It makes hydration refuse when it
|
|
29
|
+
would otherwise be incorrect.
|
|
30
|
+
|
|
31
|
+
The server stamps the id on the page, and the client bundle carries its own
|
|
32
|
+
copy. Hydration compares the two before it reads the Flags payload or adopts
|
|
33
|
+
DOM. When they differ, startup stops and the document body is marked `inert`,
|
|
34
|
+
`aria-hidden`, and `data-foldkit-refused`. A nondismissable modal shield covers
|
|
35
|
+
its controls and existing top-layer content, then takes focus without closing
|
|
36
|
+
author-owned dialogs. Nothing moves, so no custom element reconnects and no
|
|
37
|
+
frame reloads.
|
|
38
|
+
|
|
39
|
+
Without that check, stale HTML from an earlier deployment can be hydrated by the
|
|
40
|
+
newer client. Where the old markup happens to line up with the new markup,
|
|
41
|
+
an input the old page called `email` can be adopted for whatever the new build
|
|
42
|
+
puts in that position, carrying what the visitor typed into it.
|
|
43
|
+
|
|
44
|
+
The comparison happens when a client boots against a page. A tab whose client
|
|
45
|
+
is already running when a deployment lands is not rechecked.
|
|
46
|
+
|
|
47
|
+
`scripts/build.mjs` takes care of this: it produces one id per build and passes
|
|
48
|
+
it to both build commands. Supply `FOLDKIT_BUILD_ID` when the two commands run
|
|
49
|
+
in separate jobs, or when you want the served id to name a deployment you can
|
|
50
|
+
look up later:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
FOLDKIT_BUILD_ID="$CI_DEPLOYMENT_ID" {{buildCommand}}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The id is public HTML and must never contain a secret or be derived from one.
|
|
57
|
+
The client and server halves of one deployment must share an id. By contrast,
|
|
58
|
+
two deployments must never share one. Reusing an id produces no warning: the
|
|
59
|
+
ids agree, so hydration proceeds. When in doubt, leave `FOLDKIT_BUILD_ID` unset
|
|
60
|
+
and let the build script generate one.
|
|
61
|
+
|
|
62
|
+
## Learn More
|
|
63
|
+
|
|
64
|
+
- [Foldkit Documentation](https://github.com/foldkit/foldkit)
|
|
65
|
+
- [Effect Documentation](https://effect.website)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "node scripts/build.mjs",
|
|
8
|
+
"start": "node dist/server/main.js",
|
|
9
|
+
"typecheck": "tsc --noEmit",
|
|
10
|
+
"format": "prettier -w .",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"lint": "oxlint src server scripts"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
// NOTE: one id names this build, and every command below is given that same
|
|
5
|
+
// id, so the client bundle and the server bundle of a deployment agree on which
|
|
6
|
+
// deployment they are. `renderToString` stamps it on the rendered root and
|
|
7
|
+
// `Runtime.hydrate` compares it before adopting any DOM, so a page left over
|
|
8
|
+
// from an earlier deployment is refused and contained rather than reconciled
|
|
9
|
+
// against a client that no longer means the same thing by it.
|
|
10
|
+
//
|
|
11
|
+
// The id is published in the HTML every visitor receives. It identifies a
|
|
12
|
+
// deployment and is never a credential, so keep secrets out of it. Set
|
|
13
|
+
// FOLDKIT_BUILD_ID to name builds from a value the deployment already has, such
|
|
14
|
+
// as a commit or a release tag; without one, each build takes a fresh id.
|
|
15
|
+
// NOTE: `??` alone would take an empty FOLDKIT_BUILD_ID as a real value, and
|
|
16
|
+
// the plugin treats empty as absent, so the build would compile no id at all and
|
|
17
|
+
// fail later at the render. Empty is unset here too.
|
|
18
|
+
const supplied = process.env.FOLDKIT_BUILD_ID
|
|
19
|
+
const buildId =
|
|
20
|
+
supplied === undefined || supplied === '' ? randomUUID() : supplied
|
|
21
|
+
|
|
22
|
+
const steps = [
|
|
23
|
+
['vite', ['build', '--outDir', 'dist/client']],
|
|
24
|
+
['vite', ['build', '--ssr', 'server/main.ts', '--outDir', 'dist/server']],
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
for (const [command, args] of steps) {
|
|
28
|
+
const { status } = spawnSync(command, args, {
|
|
29
|
+
stdio: 'inherit',
|
|
30
|
+
shell: process.platform === 'win32',
|
|
31
|
+
env: { ...process.env, FOLDKIT_BUILD_ID: buildId },
|
|
32
|
+
})
|
|
33
|
+
if (status !== 0) {
|
|
34
|
+
process.exit(status ?? 1)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { Config, Effect, FileSystem, Layer, Match as M, Option } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
Headers as HttpHeaders,
|
|
4
|
+
HttpServer,
|
|
5
|
+
HttpServerError,
|
|
6
|
+
HttpServerRequest,
|
|
7
|
+
HttpServerResponse,
|
|
8
|
+
HttpStaticServer,
|
|
9
|
+
} from 'effect/unstable/http'
|
|
10
|
+
import { Server } from 'foldkit/experimental'
|
|
11
|
+
import { createServer } from 'node:http'
|
|
12
|
+
import { dirname, resolve } from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
NodeHttpPlatform,
|
|
17
|
+
NodeHttpServer,
|
|
18
|
+
NodeRuntime,
|
|
19
|
+
NodeServices,
|
|
20
|
+
} from '@effect/platform-node'
|
|
21
|
+
|
|
22
|
+
import { renderPage } from '../src/entry.server'
|
|
23
|
+
|
|
24
|
+
const PROJECT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
|
25
|
+
const CLIENT_DIR = resolve(PROJECT_DIR, 'dist/client')
|
|
26
|
+
const DEFAULT_PORT = 3000
|
|
27
|
+
|
|
28
|
+
const PORT = Config.withDefault(Config.port('PORT'), DEFAULT_PORT)
|
|
29
|
+
|
|
30
|
+
// NOTE: the origin this deployment serves, which the entry sees as
|
|
31
|
+
// `Request.url`. It is configuration, not something a request carries: a client
|
|
32
|
+
// may send an absolute-form target, or a network-path reference such as
|
|
33
|
+
// `//elsewhere.example/page`, and a `Host` header naming any site at all.
|
|
34
|
+
// Deriving the origin from the request would let the client choose the
|
|
35
|
+
// redirects, canonical URLs, and cookie domains the entry builds from it, so a
|
|
36
|
+
// target that resolves anywhere but this origin is refused before it reaches
|
|
37
|
+
// renderPage. Set ORIGIN to the public origin when deploying behind a proxy or
|
|
38
|
+
// TLS terminator.
|
|
39
|
+
const ORIGIN = Config.option(Config.string('ORIGIN'))
|
|
40
|
+
|
|
41
|
+
const renderRequest = (
|
|
42
|
+
request: HttpServerRequest.HttpServerRequest,
|
|
43
|
+
template: string,
|
|
44
|
+
requestUrl: string,
|
|
45
|
+
) =>
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
const webRequest = yield* HttpServerRequest.toWeb(request)
|
|
48
|
+
const result = yield* Effect.promise(() =>
|
|
49
|
+
renderPage(new Request(requestUrl, webRequest)),
|
|
50
|
+
)
|
|
51
|
+
return HttpServerResponse.fromWeb(Server.toResponse(template, result))
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// NOTE: every outcome a static miss negotiates declares both headers the
|
|
55
|
+
// negotiation read. The same extensionless URL answers 404 to a script request
|
|
56
|
+
// and HTML to a navigation, so declaring only one would let a shared cache
|
|
57
|
+
// serve either response to the other kind of request.
|
|
58
|
+
const withNegotiatedVary = (
|
|
59
|
+
response: HttpServerResponse.HttpServerResponse,
|
|
60
|
+
): HttpServerResponse.HttpServerResponse =>
|
|
61
|
+
HttpServerResponse.setHeader(
|
|
62
|
+
response,
|
|
63
|
+
'vary',
|
|
64
|
+
Server.varyWith(
|
|
65
|
+
Server.varyWithAccept(
|
|
66
|
+
Option.getOrUndefined(HttpHeaders.get('vary')(response.headers)),
|
|
67
|
+
),
|
|
68
|
+
'Sec-Fetch-Dest',
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const isRouteNotFound = (error: HttpServerError.HttpServerError): boolean =>
|
|
73
|
+
error.reason._tag === 'RouteNotFound'
|
|
74
|
+
|
|
75
|
+
type RequestKind = 'Render' | 'StaticOrRender' | 'HostSettled'
|
|
76
|
+
|
|
77
|
+
// NOTE: one rule for every method, matching the Vite dev host so a form action
|
|
78
|
+
// or a `Server.Responded` reply cannot work in development and fail only after
|
|
79
|
+
// deployment. Static files answer GET and HEAD; every other request reaches the
|
|
80
|
+
// server entry, which decides what to do with it. An entry that has no answer
|
|
81
|
+
// for a method returns its own response rather than the host guessing one.
|
|
82
|
+
//
|
|
83
|
+
// `/` and `/index.html` (and the encoded paths that resolve to them) are
|
|
84
|
+
// application requests even though a file exists for them: the file on disk is
|
|
85
|
+
// the unfilled template, and serving it raw would hand the browser an unstamped
|
|
86
|
+
// shell that Runtime.hydrate refuses.
|
|
87
|
+
const requestKind = ({
|
|
88
|
+
method,
|
|
89
|
+
url,
|
|
90
|
+
}: HttpServerRequest.HttpServerRequest): RequestKind =>
|
|
91
|
+
M.value(method).pipe(
|
|
92
|
+
M.withReturnType<RequestKind>(),
|
|
93
|
+
M.whenOr('GET', 'HEAD', () =>
|
|
94
|
+
Server.resolvesToIndexHtml(url) ? 'Render' : 'StaticOrRender',
|
|
95
|
+
),
|
|
96
|
+
M.orElse(() =>
|
|
97
|
+
Server.isHostSettledMethod(method) ? 'HostSettled' : 'Render',
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
// NOTE: CONNECT, TRACE, and TRACK never reach the entry, in this host or under
|
|
102
|
+
// Vite: the WHATWG `Request` constructor rejects them, so forwarding one answers
|
|
103
|
+
// 500 instead of refusing. OPTIONS does reach the entry in both, which is where
|
|
104
|
+
// a preflight is answered.
|
|
105
|
+
const hostSettledResponse = () =>
|
|
106
|
+
HttpServerResponse.setHeader(
|
|
107
|
+
HttpServerResponse.empty({
|
|
108
|
+
status: Server.HOST_METHOD_ANSWERS.refusedStatus,
|
|
109
|
+
}),
|
|
110
|
+
'allow',
|
|
111
|
+
Server.HOST_METHOD_ANSWERS.allow,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const makeHandler = Effect.gen(function* () {
|
|
115
|
+
const fs = yield* FileSystem.FileSystem
|
|
116
|
+
const port = yield* PORT
|
|
117
|
+
const origin = Option.getOrElse(
|
|
118
|
+
yield* ORIGIN,
|
|
119
|
+
() => `http://localhost:${port}`,
|
|
120
|
+
)
|
|
121
|
+
const template = yield* fs.readFileString(resolve(CLIENT_DIR, 'index.html'))
|
|
122
|
+
const staticFiles = yield* HttpStaticServer.make({
|
|
123
|
+
root: CLIENT_DIR,
|
|
124
|
+
index: undefined,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// NOTE: a static miss is Accept-negotiated because a deep link into a client
|
|
128
|
+
// route has no file on disk but an HTML client should still get the app
|
|
129
|
+
// shell. It renders, anything else 404s, and both carry Vary: Accept. A miss
|
|
130
|
+
// that names an asset never renders: browsers fetch scripts and stylesheets
|
|
131
|
+
// with `Accept: */*`, so a hashed asset from a previous deployment would
|
|
132
|
+
// otherwise be answered with the app shell at 200 and read as a blank page
|
|
133
|
+
// rather than the 404 it is. That refusal does not depend on Accept, so it
|
|
134
|
+
// carries no Vary.
|
|
135
|
+
const serveStaticOrRender = (
|
|
136
|
+
request: HttpServerRequest.HttpServerRequest,
|
|
137
|
+
requestUrl: string,
|
|
138
|
+
) =>
|
|
139
|
+
staticFiles.pipe(
|
|
140
|
+
Effect.catchIf(isRouteNotFound, () =>
|
|
141
|
+
M.value(
|
|
142
|
+
Server.classifyRequest(
|
|
143
|
+
request.url,
|
|
144
|
+
request.headers['sec-fetch-dest'],
|
|
145
|
+
),
|
|
146
|
+
).pipe(
|
|
147
|
+
M.when('PathAsset', () =>
|
|
148
|
+
Effect.succeed(HttpServerResponse.empty({ status: 404 })),
|
|
149
|
+
),
|
|
150
|
+
M.when('DestinationAsset', () =>
|
|
151
|
+
Effect.succeed(
|
|
152
|
+
withNegotiatedVary(HttpServerResponse.empty({ status: 404 })),
|
|
153
|
+
),
|
|
154
|
+
),
|
|
155
|
+
M.when('Page', () =>
|
|
156
|
+
Server.acceptsHtml(request.headers['accept'])
|
|
157
|
+
? renderRequest(request, template, requestUrl).pipe(
|
|
158
|
+
Effect.map(withNegotiatedVary),
|
|
159
|
+
)
|
|
160
|
+
: Effect.succeed(
|
|
161
|
+
withNegotiatedVary(HttpServerResponse.empty({ status: 404 })),
|
|
162
|
+
),
|
|
163
|
+
),
|
|
164
|
+
M.exhaustive,
|
|
165
|
+
),
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return HttpServerRequest.HttpServerRequest.use(request => {
|
|
170
|
+
const requestUrl = Server.resolveRequestUrl(request.url, origin)
|
|
171
|
+
if (requestUrl === undefined) {
|
|
172
|
+
return Effect.succeed(HttpServerResponse.empty({ status: 400 }))
|
|
173
|
+
}
|
|
174
|
+
const resolved = new URL(requestUrl)
|
|
175
|
+
const normalizedRequest = request.modify({
|
|
176
|
+
url: `${resolved.pathname}${resolved.search}`,
|
|
177
|
+
})
|
|
178
|
+
const response = M.value(requestKind(normalizedRequest)).pipe(
|
|
179
|
+
M.when('Render', () =>
|
|
180
|
+
renderRequest(normalizedRequest, template, requestUrl),
|
|
181
|
+
),
|
|
182
|
+
M.when('StaticOrRender', () =>
|
|
183
|
+
serveStaticOrRender(normalizedRequest, requestUrl),
|
|
184
|
+
),
|
|
185
|
+
M.when('HostSettled', () => Effect.succeed(hostSettledResponse())),
|
|
186
|
+
M.exhaustive,
|
|
187
|
+
)
|
|
188
|
+
return Effect.provideService(
|
|
189
|
+
response,
|
|
190
|
+
HttpServerRequest.HttpServerRequest,
|
|
191
|
+
normalizedRequest,
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const Main = Layer.unwrap(
|
|
197
|
+
Effect.map(makeHandler, handler => HttpServer.serve(handler)),
|
|
198
|
+
).pipe(
|
|
199
|
+
HttpServer.withLogAddress,
|
|
200
|
+
Layer.provide(NodeHttpServer.layerConfig(createServer, { port: PORT })),
|
|
201
|
+
Layer.provide(NodeHttpPlatform.layer),
|
|
202
|
+
Layer.provide(NodeServices.layer),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
NodeRuntime.runMain(Layer.launch(Main))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Number as Number_, Option, Record, pipe } from 'effect'
|
|
2
|
+
import { Cookies } from 'effect/unstable/http'
|
|
3
|
+
|
|
4
|
+
export const COUNT_COOKIE = 'count'
|
|
5
|
+
|
|
6
|
+
export const readCountCookie = (cookieHeader: string): number =>
|
|
7
|
+
pipe(
|
|
8
|
+
Cookies.parseHeader(cookieHeader),
|
|
9
|
+
Record.get(COUNT_COOKIE),
|
|
10
|
+
Option.flatMap(Number_.parse),
|
|
11
|
+
Option.filter(Number.isSafeInteger),
|
|
12
|
+
Option.getOrElse(() => 0),
|
|
13
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { Server } from 'foldkit/experimental'
|
|
3
|
+
|
|
4
|
+
import { readCountCookie } from './cookie'
|
|
5
|
+
import { Flags, init, view } from './main'
|
|
6
|
+
|
|
7
|
+
const flagsForRequest = (cookieHeader: string): Flags => ({
|
|
8
|
+
initialCount: readCountCookie(cookieHeader),
|
|
9
|
+
renderedAt: new Date().toISOString(),
|
|
10
|
+
renderedOn: 'Server',
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
// NOTE: the Flags built from this request are serialized into the rendered
|
|
14
|
+
// HTML and travel to the browser with it. The hydrating client reads them
|
|
15
|
+
// back and calls init with the exact values this render used; the client
|
|
16
|
+
// computes no Flags of its own.
|
|
17
|
+
// NOTE: a preflight reaches this entry in development and in production alike,
|
|
18
|
+
// so an application's CORS policy goes here rather than in the host: it can
|
|
19
|
+
// allow one origin for one route and refuse it for another. This answer allows
|
|
20
|
+
// nothing and only reports which methods the host forwards.
|
|
21
|
+
const preflightResponse = (): Response =>
|
|
22
|
+
new Response(null, {
|
|
23
|
+
status: 204,
|
|
24
|
+
headers: { allow: Server.HOST_METHOD_ANSWERS.allow },
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export const renderPage = (request: Request): Promise<Server.EntryResult> =>
|
|
28
|
+
Effect.runPromise(
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
if (request.method === 'OPTIONS') {
|
|
31
|
+
return Server.Responded(preflightResponse())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const renderedApplication = yield* Server.renderToString(
|
|
35
|
+
{ Flags, init, view },
|
|
36
|
+
{
|
|
37
|
+
flags: flagsForRequest(request.headers.get('cookie') ?? ''),
|
|
38
|
+
buildId: import.meta.env.FOLDKIT_BUILD_ID,
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return Server.Rendered(renderedApplication, {
|
|
43
|
+
headers: {
|
|
44
|
+
'cache-control': 'private, no-store',
|
|
45
|
+
vary: 'cookie',
|
|
46
|
+
'x-content-type-options': 'nosniff',
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
}),
|
|
50
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Runtime } from 'foldkit'
|
|
2
|
+
|
|
3
|
+
import { Flags, Message, Model, init, update, view } from './main'
|
|
4
|
+
|
|
5
|
+
const application = Runtime.makeApplication({
|
|
6
|
+
Model,
|
|
7
|
+
Flags,
|
|
8
|
+
init,
|
|
9
|
+
update,
|
|
10
|
+
view,
|
|
11
|
+
container: document.getElementById('root'),
|
|
12
|
+
devTools: {
|
|
13
|
+
Message,
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
Runtime.hydrate(application, { buildId: import.meta.env.FOLDKIT_BUILD_ID })
|