@foldkit/node 0.0.0 → 0.1.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/README.md CHANGED
@@ -30,6 +30,8 @@ NodeRuntime.runMain(Node.serve({ port, origin }))
30
30
  `origin` is the public URL of the deployment. When it is `None`, the adapter
31
31
  uses `http://localhost:<port>`. Set it behind a proxy or TLS terminator so the
32
32
  server entry receives the public `Request.url`.
33
+ The supplied value must be an HTTP or HTTPS origin without credentials, a path,
34
+ query, or fragment. Invalid values fail during startup.
33
35
 
34
36
  The adapter serves matching client files only for `GET` and `HEAD`. It sends
35
37
  the Vite-base `index.html` path, such as `/app/index.html` for `/app/`, and
package/dist/node.d.ts CHANGED
@@ -6,7 +6,9 @@ export type ServeOptions = Readonly<{
6
6
  port: Config.Config<number>;
7
7
  /**
8
8
  * The origin Config for requests that reach the Fetch handler. Use `None`
9
- * to derive `http://localhost:<port>` from the resolved port.
9
+ * to derive `http://localhost:<port>` from the resolved port. A supplied
10
+ * origin must use HTTP or HTTPS and have no credentials, path, query, or
11
+ * fragment.
10
12
  */
11
13
  origin: Config.Config<Option.Option<string>>;
12
14
  /**
@@ -1 +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"}
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;AAmCpB,8EAA8E;AAC9E,MAAM,MAAM,YAAY,GAAG,QAAQ,CAAC;IAClC,uDAAuD;IACvD,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IAC3B;;;;;OAKG;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;AAoWF;;;;;;GAMG;AACH,eAAO,MAAM,KAAK,YAAa,YAAY,4IACqC,CAAA"}
package/dist/node.js CHANGED
@@ -7,8 +7,10 @@ import { dirname, isAbsolute, posix, relative, resolve, sep } from 'node:path';
7
7
  import { Readable } from 'node:stream';
8
8
  import { ReadableStream } from 'node:stream/web';
9
9
  import { pathToFileURL } from 'node:url';
10
- import { NodeHttpPlatform, NodeHttpServer, NodeHttpServerRequest, NodeServices, } from '@effect/platform-node';
10
+ import { NodeHttpServer, NodeHttpServerRequest } from '@effect/platform-node';
11
11
  const DEFAULT_MANIFEST_PATH = 'dist/server/foldkit.build.json';
12
+ const INVALID_ORIGIN_MESSAGE = 'origin must be an HTTP or HTTPS origin without credentials, path, query, or fragment';
13
+ const ORIGIN_SYNTAX = /^https?:\/\/[^/?#\\\s@]+\/?$/i;
12
14
  const FoldkitBuildManifest = Schema.Struct({
13
15
  schemaVersion: Schema.Literals([1]),
14
16
  client: Schema.String,
@@ -22,26 +24,43 @@ const isRouteNotFound = (error) => error.reason._tag === 'RouteNotFound';
22
24
  const hostSettledResponse = () => HttpServerResponse.setHeader(HttpServerResponse.empty({
23
25
  status: Server.HOST_METHOD_ANSWERS.refusedStatus,
24
26
  }), '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');
27
+ const isPublicOriginUrl = (url) => (url.protocol === 'http:' || url.protocol === 'https:') &&
28
+ url.username === '' &&
29
+ url.password === '' &&
30
+ url.pathname === '/' &&
31
+ url.search === '' &&
32
+ url.hash === '';
33
+ const originFrom = (origin) => Effect.gen(function* () {
34
+ if (!ORIGIN_SYNTAX.test(origin)) {
35
+ return yield* Effect.fail(new Error(INVALID_ORIGIN_MESSAGE));
30
36
  }
31
- catch {
32
- throw new Error('basePath must be a root-relative path ending with `/`');
37
+ const url = yield* Effect.try({
38
+ try: () => new URL(origin),
39
+ catch: () => new Error(INVALID_ORIGIN_MESSAGE),
40
+ });
41
+ if (!isPublicOriginUrl(url)) {
42
+ return yield* Effect.fail(new Error(INVALID_ORIGIN_MESSAGE));
33
43
  }
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 `/`');
44
+ return url.origin;
45
+ });
46
+ const isRootRelativeBasePath = (basePath, url) => basePath.startsWith('/') &&
47
+ !basePath.startsWith('//') &&
48
+ !basePath.includes('\\') &&
49
+ basePath.endsWith('/') &&
50
+ url.origin === 'http://localhost' &&
51
+ url.search === '' &&
52
+ url.hash === '';
53
+ const basePathFrom = (basePath) => Effect.gen(function* () {
54
+ const resolvedBasePath = basePath ?? '/';
55
+ const url = yield* Effect.try({
56
+ try: () => new URL(resolvedBasePath, 'http://localhost'),
57
+ catch: () => new Error('basePath must be a root-relative path ending with `/`'),
58
+ });
59
+ if (!isRootRelativeBasePath(resolvedBasePath, url)) {
60
+ return yield* Effect.fail(new Error('basePath must be a root-relative path ending with `/`'));
42
61
  }
43
62
  return url.pathname;
44
- };
63
+ });
45
64
  const staticPathFor = (pathname, basePath) => {
46
65
  if (basePath === '/') {
47
66
  return pathname;
@@ -51,51 +70,65 @@ const staticPathFor = (pathname, basePath) => {
51
70
  }
52
71
  return `/${pathname.slice(basePath.length)}`;
53
72
  };
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)));
73
+ const decodeManifest = (content) => Effect.gen(function* () {
74
+ const parsed = yield* Effect.try({
75
+ try: () => JSON.parse(content),
76
+ catch: cause => new Error('foldkit.build.json is not valid JSON', { cause }),
77
+ });
78
+ return yield* Schema.decodeUnknownEffect(FoldkitBuildManifest)(parsed);
79
+ });
58
80
  const buildPaths = (manifestPath, manifest, rootDirectory) => {
59
81
  const serverDirectory = dirname(manifestPath);
60
82
  const normalizedServerDirectory = posix.normalize(manifest.server);
61
83
  if (rootDirectory === undefined &&
62
84
  (normalizedServerDirectory === '..' ||
63
85
  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.');
86
+ return Effect.fail(new Error('foldkit.build.json describes a server directory outside the Vite root. Pass rootDirectory so @foldkit/node can locate the client output.'));
65
87
  }
66
88
  const resolvedRootDirectory = rootDirectory === undefined
67
89
  ? resolve(serverDirectory, relative(manifest.server, '.'))
68
90
  : resolve(process.cwd(), rootDirectory);
69
91
  const expectedServerDirectory = resolve(resolvedRootDirectory, manifest.server);
70
92
  if (expectedServerDirectory !== serverDirectory) {
71
- throw new Error(`rootDirectory resolves ${manifest.server} to ${expectedServerDirectory}, but the manifest is at ${serverDirectory}`);
93
+ return Effect.fail(new Error(`rootDirectory resolves ${manifest.server} to ${expectedServerDirectory}, but the manifest is at ${serverDirectory}`));
72
94
  }
73
- return {
95
+ return Effect.succeed({
74
96
  clientDirectory: resolve(resolvedRootDirectory, manifest.client),
75
97
  fetchHandlerPath: resolve(serverDirectory, manifest.serverEntry),
76
- };
98
+ });
77
99
  };
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`))));
100
+ const readBuildPaths = (manifestPath, rootDirectory) => Effect.gen(function* () {
101
+ const content = yield* Effect.tryPromise({
102
+ try: () => readFile(manifestPath, 'utf8'),
103
+ catch: cause => new Error(`could not read Foldkit build manifest at ${manifestPath}`, {
104
+ cause,
105
+ }),
106
+ });
107
+ const manifest = yield* decodeManifest(content);
108
+ return yield* buildPaths(manifestPath, manifest, rootDirectory);
109
+ });
110
+ const loadFetchHandler = (fetchHandlerPath) => Effect.gen(function* () {
111
+ const loaded = yield* Effect.tryPromise({
112
+ try: () => import(pathToFileURL(fetchHandlerPath).href),
113
+ catch: cause => new Error(`could not load the Foldkit fetch handler at ${fetchHandlerPath}. Run \`vite build\` first.`, { cause }),
114
+ });
115
+ if (!isFetchModule(loaded)) {
116
+ return yield* Effect.fail(new Error(`${fetchHandlerPath} must default-export a Web fetch handler`));
117
+ }
118
+ return loaded.default;
119
+ });
90
120
  const fetchResponse = (app, request, requestUrl) => Effect.gen(function* () {
91
121
  const signal = yield* Effect.abortSignal;
92
122
  const webRequest = yield* HttpServerRequest.toWeb(request, { signal });
123
+ // NOTE: A rejected Fetch handler promise is an unhandled application
124
+ // defect. Expected application failures should be returned as Responses.
93
125
  const response = yield* Effect.promise(() => app.fetch(new Request(requestUrl, webRequest)));
94
126
  const headers = new Headers(response.headers);
95
127
  const setCookieHeaders = headers.getSetCookie();
96
128
  headers.delete('set-cookie');
129
+ const nodeResponse = NodeHttpServerRequest.toServerResponse(request);
97
130
  if (setCookieHeaders.length > 0) {
98
- NodeHttpServerRequest.toServerResponse(request).setHeader('set-cookie', setCookieHeaders);
131
+ nodeResponse.setHeader('set-cookie', setCookieHeaders);
99
132
  }
100
133
  const responseOptions = {
101
134
  status: response.status,
@@ -118,7 +151,6 @@ const fetchResponse = (app, request, requestUrl) => Effect.gen(function* () {
118
151
  },
119
152
  cancel: reason => webReader.cancel(reason),
120
153
  }));
121
- const nodeResponse = NodeHttpServerRequest.toServerResponse(request);
122
154
  const destroyBody = () => nodeBody.destroy();
123
155
  nodeResponse.once('close', destroyBody);
124
156
  nodeBody.once('close', () => nodeResponse.off('close', destroyBody));
@@ -131,16 +163,17 @@ const confinedStaticFiles = (clientDirectory) => Effect.gen(function* () {
131
163
  const fileSystem = yield* FileSystem.FileSystem;
132
164
  const httpPlatform = yield* HttpPlatform.HttpPlatform;
133
165
  const root = yield* fileSystem.realPath(clientDirectory);
134
- const confinedRealPath = (path) => Effect.flatMap(fileSystem.realPath(path), canonicalPath => {
166
+ const confinedRealPath = (path) => Effect.gen(function* () {
167
+ const canonicalPath = yield* fileSystem.realPath(path);
135
168
  const fromRoot = relative(root, canonicalPath);
136
169
  const isContained = fromRoot === '' ||
137
170
  (fromRoot !== '..' &&
138
171
  !fromRoot.startsWith(`..${sep}`) &&
139
172
  !isAbsolute(fromRoot));
140
173
  if (isContained) {
141
- return Effect.succeed(canonicalPath);
174
+ return canonicalPath;
142
175
  }
143
- return Effect.fail(PlatformError.systemError({
176
+ return yield* Effect.fail(PlatformError.systemError({
144
177
  _tag: 'NotFound',
145
178
  module: 'FileSystem',
146
179
  method: 'realPath',
@@ -157,13 +190,12 @@ const confinedStaticFiles = (clientDirectory) => Effect.gen(function* () {
157
190
  });
158
191
  return yield* HttpStaticServer.make({ root, index: undefined }).pipe(Effect.provideService(FileSystem.FileSystem, confinedFileSystem), Effect.provideService(HttpPlatform.HttpPlatform, confinedPlatform));
159
192
  });
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)}`);
193
+ const makeHandler = (options, port) => Effect.gen(function* () {
194
+ const origin = yield* originFrom(Option.getOrElse(yield* options.origin, () => `http://localhost:${globalThis.String(port)}`));
163
195
  const manifestPath = resolve(process.cwd(), options.manifestPath ?? DEFAULT_MANIFEST_PATH);
164
196
  const paths = yield* readBuildPaths(manifestPath, options.rootDirectory);
197
+ const basePath = yield* basePathFrom(options.basePath);
165
198
  const app = yield* loadFetchHandler(paths.fetchHandlerPath);
166
- const basePath = basePathFrom(options.basePath);
167
199
  const staticFiles = yield* confinedStaticFiles(paths.clientDirectory);
168
200
  return HttpServerRequest.HttpServerRequest.use(request => {
169
201
  const requestUrl = Server.resolveRequestUrl(request.url, origin);
@@ -193,7 +225,7 @@ const makeHandler = (options) => Effect.gen(function* () {
193
225
  return Effect.provideService(response, HttpServerRequest.HttpServerRequest, staticRequest ?? fetchRequest);
194
226
  }).pipe(Effect.interruptible);
195
227
  });
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));
228
+ const serverLayer = (options, port) => Layer.unwrap(Effect.map(makeHandler(options, port), handler => HttpServer.serve(handler))).pipe(HttpServer.withLogAddress, Layer.provide(NodeHttpServer.layer(createServer, { port })));
197
229
  /**
198
230
  * Starts a Node HTTP server for a built Foldkit application.
199
231
  *
@@ -201,4 +233,4 @@ const serverLayer = (options) => Layer.unwrap(Effect.map(makeHandler(options), h
201
233
  * and HEAD requests, and sends every other application request to the emitted
202
234
  * Web Fetch handler. Interrupting the returned Effect closes the HTTP server.
203
235
  */
204
- export const serve = (options) => Layer.launch(serverLayer(options));
236
+ export const serve = (options) => Effect.flatMap(options.port, port => Layer.launch(serverLayer(options, port)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldkit/node",
3
- "version": "0.0.0",
3
+ "version": "0.1.0",
4
4
  "description": "Node.js host adapter for Foldkit server-rendered applications",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -26,10 +26,10 @@
26
26
  "devDependencies": {
27
27
  "@types/node": "26.4.0",
28
28
  "effect": "4.0.0",
29
+ "foldkit": "0.167.0",
29
30
  "rimraf": "^6.1.3",
30
31
  "typescript": "^7.0.2",
31
- "vitest": "^4.1.11",
32
- "foldkit": "0.166.0"
32
+ "vitest": "^4.1.11"
33
33
  },
34
34
  "keywords": [
35
35
  "foldkit",