@foldkit/node 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Devin Jameson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,46 @@
1
- # Temporary Holding Version
1
+ # @foldkit/node
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `@foldkit/node` starts a built Foldkit server-rendered application on Node.
4
+ It reads `foldkit.build.json` to find the client output and Fetch handler.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm install @foldkit/node @effect/platform-node
10
+ ```
11
+
12
+ ## Serve a build
13
+
14
+ Build the application with `vite build`, then run a Node entry point such as:
15
+
16
+ ```ts
17
+ import { Config, Option, String } from 'effect'
18
+
19
+ import { NodeRuntime } from '@effect/platform-node'
20
+ import * as Node from '@foldkit/node'
21
+
22
+ const port = Config.withDefault(Config.Port('PORT'), 3000)
23
+ const origin = Config.option(Config.String('ORIGIN')).pipe(
24
+ Config.map(Option.filter(String.isNonEmpty)),
25
+ )
26
+
27
+ NodeRuntime.runMain(Node.serve({ port, origin }))
28
+ ```
29
+
30
+ `origin` is the public URL of the deployment. When it is `None`, the adapter
31
+ uses `http://localhost:<port>`. Set it behind a proxy or TLS terminator so the
32
+ server entry receives the public `Request.url`.
33
+
34
+ The adapter serves matching client files only for `GET` and `HEAD`. It sends
35
+ the Vite-base `index.html` path, such as `/app/index.html` for `/app/`, and
36
+ every request that does not match a static file to the Fetch handler. It does
37
+ not choose a `Cache-Control` policy or replace application 404 responses.
38
+
39
+ Pass `manifestPath` to `serve` when the manifest is not at
40
+ `dist/server/foldkit.build.json` relative to the process working directory.
41
+ When the server output is outside the Vite root, also pass `rootDirectory` so
42
+ the adapter can locate the client output recorded by the manifest.
43
+
44
+ For a Vite application built with a root-relative `base` such as `/app/`, pass
45
+ the same value as `basePath`. The adapter serves client files only under that
46
+ path and preserves it in URLs passed to the Fetch handler.
@@ -0,0 +1,3 @@
1
+ export { serve } from './node.js';
2
+ export type { ServeOptions } from './node.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,WAAW,CAAA;AAEjC,YAAY,EAAE,YAAY,EAAE,MAAM,WAAW,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { serve } from './node.js';
package/dist/node.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { Config, Effect, Option, PlatformError, Schema } from 'effect';
2
+ import { HttpServerError } from 'effect/http';
3
+ /** Configuration for a Node server that hosts a built Foldkit application. */
4
+ export type ServeOptions = Readonly<{
5
+ /** The port Config that selects the listening port. */
6
+ port: Config.Config<number>;
7
+ /**
8
+ * The origin Config for requests that reach the Fetch handler. Use `None`
9
+ * to derive `http://localhost:<port>` from the resolved port.
10
+ */
11
+ origin: Config.Config<Option.Option<string>>;
12
+ /**
13
+ * The build manifest to read. Relative paths resolve from the process
14
+ * working directory. It defaults to `dist/server/foldkit.build.json`.
15
+ */
16
+ manifestPath?: string;
17
+ /**
18
+ * The deployed directory that corresponds to the Vite root. Provide it when
19
+ * the manifest's server directory is outside that root. Relative paths
20
+ * resolve from the process working directory.
21
+ */
22
+ rootDirectory?: string;
23
+ /**
24
+ * The root-relative Vite base path for client assets. It defaults to `/` and
25
+ * must end with `/`, such as `/app/`.
26
+ */
27
+ basePath?: string;
28
+ }>;
29
+ /**
30
+ * Starts a Node HTTP server for a built Foldkit application.
31
+ *
32
+ * The server reads `foldkit.build.json`, serves its client directory for GET
33
+ * and HEAD requests, and sends every other application request to the emitted
34
+ * Web Fetch handler. Interrupting the returned Effect closes the HTTP server.
35
+ */
36
+ export declare const serve: (options: ServeOptions) => Effect.Effect<never, Config.ConfigError | Error | PlatformError.PlatformError | Schema.SchemaError | HttpServerError.ServeError, never>;
37
+ //# sourceMappingURL=node.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,MAAM,EACN,MAAM,EAIN,MAAM,EACN,aAAa,EAEb,MAAM,EACP,MAAM,QAAQ,CAAA;AACf,OAAO,EAGL,eAAe,EAIhB,MAAM,aAAa,CAAA;AAqCpB,8EAA8E;AAC9E,MAAM,MAAM,YAAY,GAAG,QAAQ,CAAC;IAClC,uDAAuD;IACvD,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IAC3B;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAA;IAC5C;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,CAAC,CAAA;AAmTF;;;;;;GAMG;AACH,eAAO,MAAM,KAAK,YAAa,YAAY,4IACP,CAAA"}
package/dist/node.js ADDED
@@ -0,0 +1,204 @@
1
+ import { Effect, FileSystem, Layer, Match, Option, PlatformError, Predicate, Schema, } from 'effect';
2
+ import { HttpPlatform, HttpServer, HttpServerRequest, HttpServerResponse, HttpStaticServer, } from 'effect/http';
3
+ import { Server } from 'foldkit/experimental';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { createServer } from 'node:http';
6
+ import { dirname, isAbsolute, posix, relative, resolve, sep } from 'node:path';
7
+ import { Readable } from 'node:stream';
8
+ import { ReadableStream } from 'node:stream/web';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { NodeHttpPlatform, NodeHttpServer, NodeHttpServerRequest, NodeServices, } from '@effect/platform-node';
11
+ const DEFAULT_MANIFEST_PATH = 'dist/server/foldkit.build.json';
12
+ const FoldkitBuildManifest = Schema.Struct({
13
+ schemaVersion: Schema.Literals([1]),
14
+ client: Schema.String,
15
+ server: Schema.String,
16
+ serverEntry: Schema.String,
17
+ prerendered: Schema.Array(Schema.String),
18
+ });
19
+ const isFetchHandler = (value) => Predicate.hasProperty(value, 'fetch') && Predicate.isFunction(value.fetch);
20
+ const isFetchModule = (value) => Predicate.hasProperty(value, 'default') && isFetchHandler(value.default);
21
+ const isRouteNotFound = (error) => error.reason._tag === 'RouteNotFound';
22
+ const hostSettledResponse = () => HttpServerResponse.setHeader(HttpServerResponse.empty({
23
+ status: Server.HOST_METHOD_ANSWERS.refusedStatus,
24
+ }), 'allow', Server.HOST_METHOD_ANSWERS.allow);
25
+ const basePathFrom = (basePath) => {
26
+ const resolvedBasePath = basePath ?? '/';
27
+ let url;
28
+ try {
29
+ url = new URL(resolvedBasePath, 'http://localhost');
30
+ }
31
+ catch {
32
+ throw new Error('basePath must be a root-relative path ending with `/`');
33
+ }
34
+ if (!resolvedBasePath.startsWith('/') ||
35
+ resolvedBasePath.startsWith('//') ||
36
+ resolvedBasePath.includes('\\') ||
37
+ !resolvedBasePath.endsWith('/') ||
38
+ url.origin !== 'http://localhost' ||
39
+ url.search !== '' ||
40
+ url.hash !== '') {
41
+ throw new Error('basePath must be a root-relative path ending with `/`');
42
+ }
43
+ return url.pathname;
44
+ };
45
+ const staticPathFor = (pathname, basePath) => {
46
+ if (basePath === '/') {
47
+ return pathname;
48
+ }
49
+ if (!pathname.startsWith(basePath)) {
50
+ return undefined;
51
+ }
52
+ return `/${pathname.slice(basePath.length)}`;
53
+ };
54
+ const decodeManifest = (content) => Effect.try({
55
+ try: () => JSON.parse(content),
56
+ catch: cause => new Error('foldkit.build.json is not valid JSON', { cause }),
57
+ }).pipe(Effect.flatMap(Schema.decodeUnknownEffect(FoldkitBuildManifest)));
58
+ const buildPaths = (manifestPath, manifest, rootDirectory) => {
59
+ const serverDirectory = dirname(manifestPath);
60
+ const normalizedServerDirectory = posix.normalize(manifest.server);
61
+ if (rootDirectory === undefined &&
62
+ (normalizedServerDirectory === '..' ||
63
+ normalizedServerDirectory.startsWith('../'))) {
64
+ throw new Error('foldkit.build.json describes a server directory outside the Vite root. Pass rootDirectory so @foldkit/node can locate the client output.');
65
+ }
66
+ const resolvedRootDirectory = rootDirectory === undefined
67
+ ? resolve(serverDirectory, relative(manifest.server, '.'))
68
+ : resolve(process.cwd(), rootDirectory);
69
+ const expectedServerDirectory = resolve(resolvedRootDirectory, manifest.server);
70
+ if (expectedServerDirectory !== serverDirectory) {
71
+ throw new Error(`rootDirectory resolves ${manifest.server} to ${expectedServerDirectory}, but the manifest is at ${serverDirectory}`);
72
+ }
73
+ return {
74
+ clientDirectory: resolve(resolvedRootDirectory, manifest.client),
75
+ fetchHandlerPath: resolve(serverDirectory, manifest.serverEntry),
76
+ };
77
+ };
78
+ const readBuildPaths = (manifestPath, rootDirectory) => Effect.tryPromise({
79
+ try: () => readFile(manifestPath, 'utf8'),
80
+ catch: cause => new Error(`could not read Foldkit build manifest at ${manifestPath}`, {
81
+ cause,
82
+ }),
83
+ }).pipe(Effect.flatMap(decodeManifest), Effect.map(manifest => buildPaths(manifestPath, manifest, rootDirectory)));
84
+ const loadFetchHandler = (fetchHandlerPath) => Effect.tryPromise({
85
+ try: () => import(pathToFileURL(fetchHandlerPath).href),
86
+ catch: cause => new Error(`could not load the Foldkit fetch handler at ${fetchHandlerPath}. Run \`vite build\` first.`, { cause }),
87
+ }).pipe(Effect.flatMap(loaded => isFetchModule(loaded)
88
+ ? Effect.succeed(loaded.default)
89
+ : Effect.fail(new Error(`${fetchHandlerPath} must default-export a Web fetch handler`))));
90
+ const fetchResponse = (app, request, requestUrl) => Effect.gen(function* () {
91
+ const signal = yield* Effect.abortSignal;
92
+ const webRequest = yield* HttpServerRequest.toWeb(request, { signal });
93
+ const response = yield* Effect.promise(() => app.fetch(new Request(requestUrl, webRequest)));
94
+ const headers = new Headers(response.headers);
95
+ const setCookieHeaders = headers.getSetCookie();
96
+ headers.delete('set-cookie');
97
+ if (setCookieHeaders.length > 0) {
98
+ NodeHttpServerRequest.toServerResponse(request).setHeader('set-cookie', setCookieHeaders);
99
+ }
100
+ const responseOptions = {
101
+ status: response.status,
102
+ statusText: response.statusText,
103
+ headers,
104
+ };
105
+ if (response.body === null) {
106
+ return HttpServerResponse.empty(responseOptions);
107
+ }
108
+ const webReader = response.body.getReader();
109
+ const nodeBody = Readable.fromWeb(new ReadableStream({
110
+ async pull(controller) {
111
+ const chunk = await webReader.read();
112
+ if (chunk.done) {
113
+ controller.close();
114
+ }
115
+ else {
116
+ controller.enqueue(chunk.value);
117
+ }
118
+ },
119
+ cancel: reason => webReader.cancel(reason),
120
+ }));
121
+ const nodeResponse = NodeHttpServerRequest.toServerResponse(request);
122
+ const destroyBody = () => nodeBody.destroy();
123
+ nodeResponse.once('close', destroyBody);
124
+ nodeBody.once('close', () => nodeResponse.off('close', destroyBody));
125
+ if (signal.aborted) {
126
+ nodeBody.destroy();
127
+ }
128
+ return HttpServerResponse.raw(nodeBody, responseOptions);
129
+ });
130
+ const confinedStaticFiles = (clientDirectory) => Effect.gen(function* () {
131
+ const fileSystem = yield* FileSystem.FileSystem;
132
+ const httpPlatform = yield* HttpPlatform.HttpPlatform;
133
+ const root = yield* fileSystem.realPath(clientDirectory);
134
+ const confinedRealPath = (path) => Effect.flatMap(fileSystem.realPath(path), canonicalPath => {
135
+ const fromRoot = relative(root, canonicalPath);
136
+ const isContained = fromRoot === '' ||
137
+ (fromRoot !== '..' &&
138
+ !fromRoot.startsWith(`..${sep}`) &&
139
+ !isAbsolute(fromRoot));
140
+ if (isContained) {
141
+ return Effect.succeed(canonicalPath);
142
+ }
143
+ return Effect.fail(PlatformError.systemError({
144
+ _tag: 'NotFound',
145
+ module: 'FileSystem',
146
+ method: 'realPath',
147
+ pathOrDescriptor: path,
148
+ }));
149
+ });
150
+ const confinedFileSystem = FileSystem.FileSystem.of({
151
+ ...fileSystem,
152
+ stat: path => Effect.flatMap(confinedRealPath(path), fileSystem.stat),
153
+ });
154
+ const confinedPlatform = HttpPlatform.HttpPlatform.of({
155
+ ...httpPlatform,
156
+ fileResponse: (path, options) => Effect.flatMap(confinedRealPath(path), canonicalPath => httpPlatform.fileResponse(canonicalPath, options)),
157
+ });
158
+ return yield* HttpStaticServer.make({ root, index: undefined }).pipe(Effect.provideService(FileSystem.FileSystem, confinedFileSystem), Effect.provideService(HttpPlatform.HttpPlatform, confinedPlatform));
159
+ });
160
+ const makeHandler = (options) => Effect.gen(function* () {
161
+ const port = yield* options.port;
162
+ const origin = Option.getOrElse(yield* options.origin, () => `http://localhost:${globalThis.String(port)}`);
163
+ const manifestPath = resolve(process.cwd(), options.manifestPath ?? DEFAULT_MANIFEST_PATH);
164
+ const paths = yield* readBuildPaths(manifestPath, options.rootDirectory);
165
+ const app = yield* loadFetchHandler(paths.fetchHandlerPath);
166
+ const basePath = basePathFrom(options.basePath);
167
+ const staticFiles = yield* confinedStaticFiles(paths.clientDirectory);
168
+ return HttpServerRequest.HttpServerRequest.use(request => {
169
+ const requestUrl = Server.resolveRequestUrl(request.url, origin);
170
+ if (requestUrl === undefined) {
171
+ return Effect.succeed(HttpServerResponse.empty({ status: 400 }));
172
+ }
173
+ const resolved = new URL(requestUrl);
174
+ const fetchRequest = request.modify({
175
+ url: `${resolved.pathname}${resolved.search}`,
176
+ });
177
+ const staticPath = staticPathFor(resolved.pathname, basePath);
178
+ const staticRequest = staticPath === undefined
179
+ ? undefined
180
+ : request.modify({ url: `${staticPath}${resolved.search}` });
181
+ const response = Match.value(fetchRequest.method).pipe(Match.whenOr('GET', 'HEAD', () => {
182
+ if (staticRequest === undefined ||
183
+ Server.resolvesToIndexHtml(staticRequest.url)) {
184
+ return fetchResponse(app, fetchRequest, requestUrl);
185
+ }
186
+ return staticFiles.pipe(Effect.catchIf(isRouteNotFound, () => fetchResponse(app, fetchRequest, requestUrl)));
187
+ }), Match.orElse(() => {
188
+ if (Server.isHostSettledMethod(fetchRequest.method)) {
189
+ return Effect.succeed(hostSettledResponse());
190
+ }
191
+ return fetchResponse(app, fetchRequest, requestUrl);
192
+ }));
193
+ return Effect.provideService(response, HttpServerRequest.HttpServerRequest, staticRequest ?? fetchRequest);
194
+ }).pipe(Effect.interruptible);
195
+ });
196
+ const serverLayer = (options) => Layer.unwrap(Effect.map(makeHandler(options), handler => HttpServer.serve(handler))).pipe(HttpServer.withLogAddress, Layer.provide(NodeHttpServer.layerConfig(createServer, { port: options.port })), Layer.provide(NodeHttpPlatform.layer), Layer.provide(NodeServices.layer));
197
+ /**
198
+ * Starts a Node HTTP server for a built Foldkit application.
199
+ *
200
+ * The server reads `foldkit.build.json`, serves its client directory for GET
201
+ * and HEAD requests, and sends every other application request to the emitted
202
+ * Web Fetch handler. Interrupting the returned Effect closes the HTTP server.
203
+ */
204
+ export const serve = (options) => Layer.launch(serverLayer(options));
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@foldkit/node",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.0",
4
+ "description": "Node.js host adapter for Foldkit server-rendered applications",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "sideEffects": false,
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "dependencies": {
20
+ "@effect/platform-node": "4.0.0"
21
+ },
22
+ "peerDependencies": {
23
+ "effect": "4.0.0",
24
+ "foldkit": ">=0.165.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "26.4.0",
28
+ "effect": "4.0.0",
29
+ "rimraf": "^6.1.3",
30
+ "typescript": "^7.0.2",
31
+ "vitest": "^4.1.11",
32
+ "foldkit": "0.166.0"
33
+ },
34
+ "keywords": [
35
+ "foldkit",
36
+ "node",
37
+ "server-rendering",
38
+ "ssr"
39
+ ],
40
+ "author": "Devin Jameson",
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/foldkit/foldkit.git",
45
+ "directory": "packages/node"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "engines": {
51
+ "node": ">=20.19.0"
52
+ },
53
+ "scripts": {
54
+ "clean": "rimraf dist *.tsbuildinfo",
55
+ "build": "pnpm run clean && tsc -b tsconfig.build.json",
56
+ "watch": "tsc -b tsconfig.build.json --watch",
57
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
58
+ "test": "vitest run"
59
+ }
6
60
  }