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.
Files changed (32) hide show
  1. package/README.md +12 -2
  2. package/dist/commands/create.js +28 -11
  3. package/dist/index.js +4 -1
  4. package/dist/rendering.js +20 -0
  5. package/dist/utils/files.js +20 -4
  6. package/dist/utils/packages.js +41 -12
  7. package/package.json +1 -1
  8. package/templates/package-managers/pnpm/pnpm-workspace.yaml +1 -0
  9. package/templates/rendering/ssg/README.md +61 -0
  10. package/templates/rendering/ssg/package.json +14 -0
  11. package/templates/rendering/ssg/scripts/build.mjs +40 -0
  12. package/templates/rendering/ssg/scripts/prerender.ts +71 -0
  13. package/templates/rendering/ssg/src/entry.server.ts +18 -0
  14. package/templates/rendering/ssg/src/entry.ts +28 -0
  15. package/templates/rendering/ssg/src/main.ts +176 -0
  16. package/templates/rendering/ssg/src/route.ts +20 -0
  17. package/templates/rendering/ssg/src/scene.test.ts +27 -0
  18. package/templates/rendering/ssg/src/vite-env.d.ts +19 -0
  19. package/templates/rendering/ssg/tsconfig.json +16 -0
  20. package/templates/rendering/ssg/vite.config.ts +17 -0
  21. package/templates/rendering/ssr/README.md +65 -0
  22. package/templates/rendering/ssr/package.json +14 -0
  23. package/templates/rendering/ssr/scripts/build.mjs +36 -0
  24. package/templates/rendering/ssr/server/main.ts +205 -0
  25. package/templates/rendering/ssr/src/cookie.ts +13 -0
  26. package/templates/rendering/ssr/src/entry.server.ts +50 -0
  27. package/templates/rendering/ssr/src/entry.ts +17 -0
  28. package/templates/rendering/ssr/src/main.ts +155 -0
  29. package/templates/rendering/ssr/src/scene.test.ts +43 -0
  30. package/templates/rendering/ssr/src/vite-env.d.ts +19 -0
  31. package/templates/rendering/ssr/tsconfig.json +16 -0
  32. 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 })