create-foldkit-app 0.27.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.
@@ -1,7 +1,7 @@
1
1
  import { Array, Effect, FileSystem, Match, Option, Path, Record, Ref, Schema, String, pipe, } from 'effect';
2
2
  import { HttpClient, HttpClientRequest, } from 'effect/unstable/http';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { devCommand, installCommand } from './packages.js';
4
+ import { devCommand, installCommand, runScriptCommand, } from './packages.js';
5
5
  const GITHUB_API_BASE_URL = 'https://api.github.com/repos/foldkit/foldkit/contents/examples';
6
6
  const getTemplateRoot = Effect.gen(function* () {
7
7
  const path = yield* Path.Path;
@@ -94,7 +94,7 @@ export const createProject = (name, projectPath, scaffold, packageManager) => Ef
94
94
  Ssr: () => Effect.void,
95
95
  }));
96
96
  });
97
- export const applyPackageManager = (readme, packageManager) => pipe(readme, String.replace('{{installCommand}}', installCommand(packageManager)), String.replace('{{devCommand}}', devCommand(packageManager)));
97
+ export const applyPackageManager = (readme, packageManager) => pipe(readme, String.replaceAll('{{installCommand}}', installCommand(packageManager)), String.replaceAll('{{devCommand}}', devCommand(packageManager)), String.replaceAll('{{buildCommand}}', runScriptCommand(packageManager, 'build')), String.replaceAll('{{previewCommand}}', runScriptCommand(packageManager, 'preview')), String.replaceAll('{{startCommand}}', runScriptCommand(packageManager, 'start')));
98
98
  const modifyBaseFiles = (projectPath, name, packageManager) => Effect.gen(function* () {
99
99
  const fs = yield* FileSystem.FileSystem;
100
100
  const path = yield* Path.Path;
@@ -9,6 +9,13 @@ const DEV_COMMANDS = {
9
9
  bun: 'bun dev',
10
10
  };
11
11
  export const devCommand = (packageManager) => DEV_COMMANDS[packageManager];
12
+ const RUN_SCRIPT_PREFIXES = {
13
+ pnpm: 'pnpm',
14
+ npm: 'npm run',
15
+ yarn: 'yarn',
16
+ bun: 'bun run',
17
+ };
18
+ export const runScriptCommand = (packageManager, script) => `${RUN_SCRIPT_PREFIXES[packageManager]} ${script}`;
12
19
  const GITHUB_RAW_BASE_URL = 'https://raw.githubusercontent.com/foldkit/foldkit/main/examples';
13
20
  const NPM_REGISTRY_BASE_URL = 'https://registry.npmjs.org';
14
21
  const FOLDKIT_SCOPE_PREFIX = '@foldkit/';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-foldkit-app",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "description": "Create Foldkit applications",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,2 +1,3 @@
1
1
  allowBuilds:
2
+ esbuild: true
2
3
  msgpackr-extract: false
@@ -0,0 +1,61 @@
1
+ # My Foldkit App
2
+
3
+ A statically generated Foldkit application built with Effect.
4
+
5
+ ## Getting Started
6
+
7
+ ```bash
8
+ {{installCommand}}
9
+ {{devCommand}}
10
+ ```
11
+
12
+ ## Building and previewing
13
+
14
+ ```bash
15
+ {{buildCommand}}
16
+ {{previewCommand}}
17
+ ```
18
+
19
+ The build script runs `scripts/build.mjs`, which builds the client bundle, builds
20
+ the server bundle, prerenders every path `src/entry.server.ts` lists, and gives
21
+ all three steps the same build id.
22
+
23
+ ## The build id
24
+
25
+ The build id does not make hydration correct. It makes hydration refuse when it
26
+ would otherwise be incorrect.
27
+
28
+ The generated page carries the id, and the client bundle carries its own copy.
29
+ Hydration compares the two before it reads the Flags payload or adopts DOM.
30
+ When they differ, startup stops and the document body is marked `inert`,
31
+ `aria-hidden`, and `data-foldkit-refused`. A nondismissable modal shield covers
32
+ its controls and existing top-layer content, then takes focus without closing
33
+ author-owned dialogs. Nothing moves, so no custom element reconnects and no
34
+ frame reloads.
35
+
36
+ Without that check, stale HTML from an earlier deployment can be hydrated by the
37
+ newer client. Where the old markup happens to line up with the new markup,
38
+ an input the old page called `email` can be adopted for whatever the new build
39
+ puts in that position, carrying what the visitor typed into it.
40
+
41
+ The comparison happens when a client boots against a page. A tab whose client
42
+ is already running when a deployment lands is not rechecked.
43
+
44
+ `scripts/build.mjs` takes care of this: it produces one id per build and passes
45
+ it to every step. Supply `FOLDKIT_BUILD_ID` when those steps run in separate
46
+ jobs, or when you want the served id to name a deployment you can look up later:
47
+
48
+ ```bash
49
+ FOLDKIT_BUILD_ID="$CI_DEPLOYMENT_ID" {{buildCommand}}
50
+ ```
51
+
52
+ The id is public HTML and must never contain a secret or be derived from one.
53
+ Every step of one deployment must share an id. By contrast, two deployments
54
+ must never share one. Reusing an id produces no warning: the ids agree, so
55
+ hydration proceeds. When in doubt, leave `FOLDKIT_BUILD_ID` unset and let the
56
+ build script generate one.
57
+
58
+ ## Learn More
59
+
60
+ - [Foldkit Documentation](https://github.com/foldkit/foldkit)
61
+ - [Effect Documentation](https://effect.website)
@@ -4,7 +4,7 @@
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
7
- "build": "vite build --outDir dist/client && vite build --ssr src/entry.server.ts --outDir dist/server && tsx scripts/prerender.ts",
7
+ "build": "node scripts/build.mjs",
8
8
  "preview": "vite preview --outDir dist/client",
9
9
  "typecheck": "tsc --noEmit",
10
10
  "format": "prettier -w .",
@@ -0,0 +1,40 @@
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 prerendered pages 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
+ [
25
+ 'vite',
26
+ ['build', '--ssr', 'src/entry.server.ts', '--outDir', 'dist/server'],
27
+ ],
28
+ ['tsx', ['scripts/prerender.ts']],
29
+ ]
30
+
31
+ for (const [command, args] of steps) {
32
+ const { status } = spawnSync(command, args, {
33
+ stdio: 'inherit',
34
+ shell: process.platform === 'win32',
35
+ env: { ...process.env, FOLDKIT_BUILD_ID: buildId },
36
+ })
37
+ if (status !== 0) {
38
+ process.exit(status ?? 1)
39
+ }
40
+ }
@@ -10,7 +10,7 @@ export const renderPage = (request: Request): Promise<Server.EntryResult> =>
10
10
  Effect.gen(function* () {
11
11
  const renderedApplication = yield* Server.renderToString(
12
12
  { routing: {}, init, view },
13
- { url: request.url },
13
+ { url: request.url, buildId: import.meta.env.FOLDKIT_BUILD_ID },
14
14
  )
15
15
 
16
16
  return Server.Rendered(renderedApplication)
@@ -25,4 +25,4 @@ const application = Runtime.makeApplication({
25
25
  },
26
26
  })
27
27
 
28
- Runtime.hydrate(application)
28
+ Runtime.hydrate(application, { buildId: import.meta.env.FOLDKIT_BUILD_ID })
@@ -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,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)
@@ -4,11 +4,11 @@
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
7
- "build": "vite build --outDir dist/client && vite build --ssr server/main.ts --outDir dist/server",
7
+ "build": "node scripts/build.mjs",
8
8
  "start": "node dist/server/main.js",
9
9
  "typecheck": "tsc --noEmit",
10
10
  "format": "prettier -w .",
11
11
  "test": "vitest run",
12
- "lint": "oxlint src server"
12
+ "lint": "oxlint src server scripts"
13
13
  }
14
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
+ }
@@ -25,42 +25,65 @@ const PROJECT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
25
25
  const CLIENT_DIR = resolve(PROJECT_DIR, 'dist/client')
26
26
  const DEFAULT_PORT = 3000
27
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
+
28
41
  const renderRequest = (
29
42
  request: HttpServerRequest.HttpServerRequest,
30
43
  template: string,
44
+ requestUrl: string,
31
45
  ) =>
32
46
  Effect.gen(function* () {
33
47
  const webRequest = yield* HttpServerRequest.toWeb(request)
34
- const result = yield* Effect.promise(() => renderPage(webRequest))
48
+ const result = yield* Effect.promise(() =>
49
+ renderPage(new Request(requestUrl, webRequest)),
50
+ )
35
51
  return HttpServerResponse.fromWeb(Server.toResponse(template, result))
36
52
  })
37
53
 
38
- // NOTE: Vary: Accept keeps a shared cache from serving one client's
39
- // representation to another when a static miss is answered by content
40
- // negotiation. It is merged with any Vary the render already set, parsing
41
- // Vary as field-name tokens so Accept-Language or Accept-Encoding is never
42
- // mistaken for the Accept field.
43
- const withVaryAccept = (
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 = (
44
59
  response: HttpServerResponse.HttpServerResponse,
45
60
  ): HttpServerResponse.HttpServerResponse =>
46
61
  HttpServerResponse.setHeader(
47
62
  response,
48
63
  'vary',
49
- Server.varyWithAccept(
50
- Option.getOrUndefined(HttpHeaders.get('vary')(response.headers)),
64
+ Server.varyWith(
65
+ Server.varyWithAccept(
66
+ Option.getOrUndefined(HttpHeaders.get('vary')(response.headers)),
67
+ ),
68
+ 'Sec-Fetch-Dest',
51
69
  ),
52
70
  )
53
71
 
54
72
  const isRouteNotFound = (error: HttpServerError.HttpServerError): boolean =>
55
73
  error.reason._tag === 'RouteNotFound'
56
74
 
57
- type RequestKind = 'Render' | 'StaticOrRender' | 'MethodNotAllowed'
75
+ type RequestKind = 'Render' | 'StaticOrRender' | 'HostSettled'
58
76
 
59
- // NOTE: `/` and `/index.html` (and the encoded paths that resolve to them)
60
- // are application requests even though a file exists for them: the file on
61
- // disk is the unfilled template, and serving it raw would hand the browser an
62
- // unstamped shell that Runtime.hydrate refuses. Only GET and HEAD render; every
63
- // other method (OPTIONS, POST, ...) is refused with 405 rather than rendered.
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.
64
87
  const requestKind = ({
65
88
  method,
66
89
  url,
@@ -70,11 +93,31 @@ const requestKind = ({
70
93
  M.whenOr('GET', 'HEAD', () =>
71
94
  Server.resolvesToIndexHtml(url) ? 'Render' : 'StaticOrRender',
72
95
  ),
73
- M.orElse(() => 'MethodNotAllowed'),
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,
74
112
  )
75
113
 
76
114
  const makeHandler = Effect.gen(function* () {
77
115
  const fs = yield* FileSystem.FileSystem
116
+ const port = yield* PORT
117
+ const origin = Option.getOrElse(
118
+ yield* ORIGIN,
119
+ () => `http://localhost:${port}`,
120
+ )
78
121
  const template = yield* fs.readFileString(resolve(CLIENT_DIR, 'index.html'))
79
122
  const staticFiles = yield* HttpStaticServer.make({
80
123
  root: CLIENT_DIR,
@@ -83,45 +126,78 @@ const makeHandler = Effect.gen(function* () {
83
126
 
84
127
  // NOTE: a static miss is Accept-negotiated because a deep link into a client
85
128
  // route has no file on disk but an HTML client should still get the app
86
- // shell. It renders, anything else 404s, and both carry Vary: Accept.
87
- const serveStaticOrRender = (request: HttpServerRequest.HttpServerRequest) =>
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
+ ) =>
88
139
  staticFiles.pipe(
89
140
  Effect.catchIf(isRouteNotFound, () =>
90
- Server.acceptsHtml(request.headers['accept'])
91
- ? renderRequest(request, template).pipe(Effect.map(withVaryAccept))
92
- : Effect.succeed(
93
- withVaryAccept(HttpServerResponse.empty({ status: 404 })),
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 })),
94
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
+ ),
95
166
  ),
96
167
  )
97
168
 
98
- return HttpServerRequest.HttpServerRequest.use(request =>
99
- M.value(requestKind(request)).pipe(
100
- M.when('Render', () => renderRequest(request, template)),
101
- M.when('StaticOrRender', () => serveStaticOrRender(request)),
102
- M.when('MethodNotAllowed', () =>
103
- Effect.succeed(
104
- HttpServerResponse.setHeader(
105
- HttpServerResponse.empty({ status: 405 }),
106
- 'allow',
107
- 'GET, HEAD',
108
- ),
109
- ),
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),
110
184
  ),
185
+ M.when('HostSettled', () => Effect.succeed(hostSettledResponse())),
111
186
  M.exhaustive,
112
- ),
113
- )
187
+ )
188
+ return Effect.provideService(
189
+ response,
190
+ HttpServerRequest.HttpServerRequest,
191
+ normalizedRequest,
192
+ )
193
+ })
114
194
  })
115
195
 
116
196
  const Main = Layer.unwrap(
117
197
  Effect.map(makeHandler, handler => HttpServer.serve(handler)),
118
198
  ).pipe(
119
199
  HttpServer.withLogAddress,
120
- Layer.provide(
121
- NodeHttpServer.layerConfig(createServer, {
122
- port: Config.withDefault(Config.port('PORT'), DEFAULT_PORT),
123
- }),
124
- ),
200
+ Layer.provide(NodeHttpServer.layerConfig(createServer, { port: PORT })),
125
201
  Layer.provide(NodeHttpPlatform.layer),
126
202
  Layer.provide(NodeServices.layer),
127
203
  )
@@ -14,12 +14,29 @@ const flagsForRequest = (cookieHeader: string): Flags => ({
14
14
  // HTML and travel to the browser with it. The hydrating client reads them
15
15
  // back and calls init with the exact values this render used; the client
16
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
+
17
27
  export const renderPage = (request: Request): Promise<Server.EntryResult> =>
18
28
  Effect.runPromise(
19
29
  Effect.gen(function* () {
30
+ if (request.method === 'OPTIONS') {
31
+ return Server.Responded(preflightResponse())
32
+ }
33
+
20
34
  const renderedApplication = yield* Server.renderToString(
21
35
  { Flags, init, view },
22
- { flags: flagsForRequest(request.headers.get('cookie') ?? '') },
36
+ {
37
+ flags: flagsForRequest(request.headers.get('cookie') ?? ''),
38
+ buildId: import.meta.env.FOLDKIT_BUILD_ID,
39
+ },
23
40
  )
24
41
 
25
42
  return Server.Rendered(renderedApplication, {
@@ -14,4 +14,4 @@ const application = Runtime.makeApplication({
14
14
  },
15
15
  })
16
16
 
17
- Runtime.hydrate(application)
17
+ Runtime.hydrate(application, { buildId: import.meta.env.FOLDKIT_BUILD_ID })
@@ -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
+ }