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.
- package/dist/utils/files.js +2 -2
- package/dist/utils/packages.js +7 -0
- 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 +1 -1
- package/templates/rendering/ssg/scripts/build.mjs +40 -0
- package/templates/rendering/ssg/src/entry.server.ts +1 -1
- package/templates/rendering/ssg/src/entry.ts +1 -1
- package/templates/rendering/ssg/src/vite-env.d.ts +19 -0
- package/templates/rendering/ssr/README.md +65 -0
- package/templates/rendering/ssr/package.json +2 -2
- package/templates/rendering/ssr/scripts/build.mjs +36 -0
- package/templates/rendering/ssr/server/main.ts +117 -41
- package/templates/rendering/ssr/src/entry.server.ts +18 -1
- package/templates/rendering/ssr/src/entry.ts +1 -1
- package/templates/rendering/ssr/src/vite-env.d.ts +19 -0
package/dist/utils/files.js
CHANGED
|
@@ -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.
|
|
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;
|
package/dist/utils/packages.js
CHANGED
|
@@ -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
|
@@ -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": "
|
|
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)
|
|
@@ -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": "
|
|
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(() =>
|
|
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:
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
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.
|
|
50
|
-
|
|
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' | '
|
|
75
|
+
type RequestKind = 'Render' | 'StaticOrRender' | 'HostSettled'
|
|
58
76
|
|
|
59
|
-
// NOTE:
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
{
|
|
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, {
|
|
@@ -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
|
+
}
|