@pracht/adapter-static 0.2.1 → 1.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/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
+ import { PrachtAdapter, PrachtAdapter as PrachtAdapter$1 } from "@pracht/core/adapter";
1
2
  import { HeadMetadata } from "@pracht/core";
2
- import { PrachtAdapter } from "@pracht/vite-plugin";
3
3
  import { IncomingMessage, ServerResponse } from "node:http";
4
4
 
5
5
  //#region src/static-entry.d.ts
@@ -41,7 +41,7 @@ declare function createStaticServerEntryModule(options?: StaticAdapterOptions):
41
41
  * was requested. Configuring `fallback` requires full hydration there, because
42
42
  * that document is built from the not-found page's serialized route state.
43
43
  */
44
- declare function staticAdapter(options?: StaticAdapterOptions): PrachtAdapter;
44
+ declare function staticAdapter(options?: StaticAdapterOptions): PrachtAdapter$1;
45
45
  //#endregion
46
46
  //#region src/static-preview.d.ts
47
47
  interface StaticPreviewHandlerOptions {
@@ -61,6 +61,12 @@ interface StaticPreviewHandlerOptions {
61
61
  * export under a sub-path does.
62
62
  */
63
63
  base?: string | null;
64
+ /**
65
+ * `pracht({ trailingSlash })`. Under `"always"` or `"never"` a page asked
66
+ * for by its other spelling is redirected (308, query kept), the way a host
67
+ * configured for that URL shape does. Files are served as named.
68
+ */
69
+ trailingSlash?: "ignore" | "never" | "always" | null;
64
70
  }
65
71
  /**
66
72
  * A deliberately tiny static file server for `pracht preview` of static
@@ -72,4 +78,4 @@ interface StaticPreviewHandlerOptions {
72
78
  */
73
79
  declare function createStaticPreviewHandler(options: StaticPreviewHandlerOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
74
80
  //#endregion
75
- export { type StaticAdapterOptions, type StaticPreviewHandlerOptions, createStaticPreviewHandler, createStaticServerEntryModule, staticAdapter };
81
+ export { type PrachtAdapter, type StaticAdapterOptions, type StaticPreviewHandlerOptions, createStaticPreviewHandler, createStaticServerEntryModule, staticAdapter };
package/dist/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import { resolveStaticFile } from "@pracht/adapter-node";
4
+ import { decodeStaticPath, staticByteRange, staticPathCandidates } from "@pracht/core/server";
4
5
  //#region src/static-entry.ts
5
6
  const FALLBACK_FILENAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.html$/;
6
7
  const WINDOWS_RESERVED_FALLBACK_NAME_RE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
@@ -105,6 +106,7 @@ function createStaticServerEntryModule(options = {}) {
105
106
  " staticDir: resolve(serverDir, \"../client\"),",
106
107
  " fallback: staticExportConfig.fallback,",
107
108
  " base: buildBase,",
109
+ " trailingSlash: buildTrailingSlash,",
108
110
  " });",
109
111
  " const server = createServer(handler);",
110
112
  ` const port = Number(process.env.PORT ?? ${port});`,
@@ -115,25 +117,6 @@ function createStaticServerEntryModule(options = {}) {
115
117
  ""
116
118
  ].join("\n");
117
119
  }
118
- /**
119
- * Create a pracht adapter for pure static export.
120
- *
121
- * ```ts
122
- * import { staticAdapter } from "@pracht/adapter-static";
123
- * pracht({ adapter: staticAdapter() })
124
- * ```
125
- *
126
- * `pracht build` prerenders every route into `dist/client/` — deploy that
127
- * directory to any static host. Build-time validation fails closed on
128
- * anything that needs a server: `render: "ssr"` / `"isg"` routes, SPA
129
- * loaders, request middleware, API routes, and network-exposed capabilities.
130
- * SSG loaders run at build time; static SPA routes are loaderless.
131
- *
132
- * The `notFound` page may use `hydration: "islands"` or `"none"` to keep the
133
- * client router out of `404.html`, at the cost of the page knowing which URL
134
- * was requested. Configuring `fallback` requires full hydration there, because
135
- * that document is built from the not-found page's serialized route state.
136
- */
137
120
  function staticAdapter(options = {}) {
138
121
  assertValidStaticAdapterOptions(options);
139
122
  return {
@@ -153,19 +136,14 @@ function staticAdapter(options = {}) {
153
136
  }
154
137
  //#endregion
155
138
  //#region src/static-preview.ts
156
- /**
157
- * A deliberately tiny static file server for `pracht preview` of static
158
- * exports. It mirrors what a plain static host does — files, clean-URL
159
- * `index.html` resolution, `404.html` for misses, an optional SPA fallback —
160
- * and nothing else: no route matching, no loaders, no framework runtime.
161
- * Production deploys should use a real static host; this exists so the build
162
- * output can be exercised locally with one command.
163
- */
164
139
  function createStaticPreviewHandler(options) {
165
140
  const staticDir = resolve(options.staticDir);
166
141
  const fallback = options.fallback ?? null;
167
142
  const base = normalizePreviewBase(options.base);
168
- const decodedBase = decodeStaticPreviewPathname(new URL(base, "http://localhost").pathname);
143
+ const trailingSlash = options.trailingSlash ?? "ignore";
144
+ const decodedBase = decodePathSegments(new URL(base, "http://localhost").pathname);
145
+ if (!decodedBase) throw new Error(`Unsafe static preview base ${JSON.stringify(base)}.`);
146
+ const baseSegments = decodedBase.slice(1, -1);
169
147
  return async function handleStaticPreviewRequest(req, res) {
170
148
  const method = req.method ?? "GET";
171
149
  if (method !== "GET" && method !== "HEAD") {
@@ -178,31 +156,48 @@ function createStaticPreviewHandler(options) {
178
156
  }
179
157
  let pathname;
180
158
  let search;
159
+ let publicPathname;
181
160
  try {
182
161
  const requestUrl = new URL(req.url ?? "/", "http://localhost");
183
- pathname = decodeStaticPreviewPathname(requestUrl.pathname);
162
+ publicPathname = requestUrl.pathname;
163
+ pathname = requestUrl.pathname;
184
164
  search = requestUrl.search;
185
165
  } catch {
166
+ pathname = "";
167
+ search = "";
168
+ publicPathname = "";
169
+ }
170
+ if (!decodeStaticPath(pathname).ok || staticPathCandidates(pathname) === null) {
186
171
  res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
187
172
  res.end("Bad request");
188
173
  return;
189
174
  }
190
- if (decodedBase !== "/") {
191
- if (pathname === decodedBase.slice(0, -1)) {
175
+ if (baseSegments.length > 0) {
176
+ const segments = pathname.split("/").slice(1);
177
+ const decodedSegments = decodePathSegments(pathname)?.slice(1) ?? segments;
178
+ if (decodedSegments.length === baseSegments.length && baseSegments.every((segment, index) => decodedSegments[index] === segment)) {
192
179
  res.writeHead(301, { location: `${base}${search}` });
193
180
  res.end();
194
181
  return;
195
182
  }
196
- if (!pathname.startsWith(decodedBase)) {
183
+ if (decodedSegments.length <= baseSegments.length || !baseSegments.every((segment, index) => decodedSegments[index] === segment)) {
197
184
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
198
185
  res.end("Not found");
199
186
  return;
200
187
  }
201
- pathname = `/${pathname.slice(decodedBase.length)}`;
188
+ pathname = `/${segments.slice(baseSegments.length).join("/")}`;
202
189
  }
203
190
  const file = await resolveStaticFile(staticDir, pathname);
191
+ if (file?.document && trailingSlash !== "ignore") {
192
+ const canonical = canonicalPagePathname(publicPathname, base, trailingSlash);
193
+ if (canonical !== publicPathname) {
194
+ res.writeHead(308, { location: `${canonical.replace(/^\/{2,}/, "/")}${search}` });
195
+ res.end();
196
+ return;
197
+ }
198
+ }
204
199
  if (file) {
205
- await sendFile(res, method, file.filePath, file.contentType, file.cacheControl, 200);
200
+ await sendFile(res, method, file.filePath, file.contentType, file.cacheControl, 200, req);
206
201
  return;
207
202
  }
208
203
  if (fallback) {
@@ -221,18 +216,22 @@ function createStaticPreviewHandler(options) {
221
216
  res.end("Not found");
222
217
  };
223
218
  }
224
- /**
225
- * Decode each URL segment into the filesystem spelling emitted by the static
226
- * exporter without allowing an escape to introduce path structure.
227
- */
228
- function decodeStaticPreviewPathname(pathname) {
229
- return pathname.split("/").map((segment) => {
230
- const decoded = decodeURIComponent(segment);
231
- if (decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\") || decoded.includes("\0")) throw new Error("Unsafe encoded static path segment.");
232
- return decoded;
233
- }).join("/");
219
+ function canonicalPagePathname(pathname, base, policy) {
220
+ const trimmed = pathname.replace(/\/+$/, "");
221
+ if (trimmed === "" || `${trimmed}/` === base) return pathname;
222
+ if (policy === "never") return trimmed;
223
+ return trimmed.slice(trimmed.lastIndexOf("/") + 1).includes(".") ? trimmed : `${trimmed}/`;
224
+ }
225
+ function decodePathSegments(pathname) {
226
+ const segments = [];
227
+ for (const segment of pathname.split("/")) {
228
+ const decoded = decodeStaticPath(segment);
229
+ if (!decoded.ok) return null;
230
+ segments.push(decoded.path);
231
+ }
232
+ return segments;
234
233
  }
235
- async function sendFile(res, method, filePath, contentType, cacheControl, status) {
234
+ async function sendFile(res, method, filePath, contentType, cacheControl, status, rangeRequest) {
236
235
  let body;
237
236
  try {
238
237
  body = await readFile(filePath);
@@ -241,14 +240,40 @@ async function sendFile(res, method, filePath, contentType, cacheControl, status
241
240
  res.end("Not found");
242
241
  return;
243
242
  }
244
- res.writeHead(status, {
243
+ const headers = {
245
244
  "content-type": contentType,
246
245
  "cache-control": cacheControl,
247
246
  "content-length": body.byteLength
248
- });
247
+ };
248
+ if (rangeRequest) {
249
+ headers["accept-ranges"] = "bytes";
250
+ const range = method === "GET" ? staticByteRange({ get: (name) => headerValue(rangeRequest, name) }, { get: () => null }, body.byteLength) : null;
251
+ if (range === "unsatisfiable") {
252
+ res.writeHead(416, {
253
+ ...headers,
254
+ "content-length": 0,
255
+ "content-range": `bytes */${body.byteLength}`
256
+ });
257
+ res.end();
258
+ return;
259
+ }
260
+ if (range) {
261
+ res.writeHead(206, {
262
+ ...headers,
263
+ "content-length": range.end - range.start + 1,
264
+ "content-range": `bytes ${range.start}-${range.end}/${body.byteLength}`
265
+ });
266
+ res.end(body.subarray(range.start, range.end + 1));
267
+ return;
268
+ }
269
+ }
270
+ res.writeHead(status, headers);
249
271
  res.end(method === "HEAD" ? void 0 : body);
250
272
  }
251
- /** Vite normalizes `base` to leading and trailing slashes; be defensive anyway. */
273
+ function headerValue(req, name) {
274
+ const value = req.headers[name.toLowerCase()];
275
+ return typeof value === "string" ? value : null;
276
+ }
252
277
  function normalizePreviewBase(raw) {
253
278
  if (typeof raw !== "string" || raw === "" || raw === "/") return "/";
254
279
  const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pracht/adapter-static",
3
- "version": "0.2.1",
3
+ "version": "1.0.0",
4
4
  "description": "Static-export adapter for Pracht: prerender every route and deploy dist/client to any static host with zero server.",
5
5
  "keywords": [
6
6
  "pracht",
@@ -13,7 +13,7 @@
13
13
  "vite"
14
14
  ],
15
15
  "license": "MIT",
16
- "homepage": "https://github.com/JoviDeCroock/pracht/tree/main/packages/adapter-static",
16
+ "homepage": "https://pracht.resynapse.dev/docs/adapters",
17
17
  "bugs": {
18
18
  "url": "https://github.com/JoviDeCroock/pracht/issues"
19
19
  },
@@ -39,8 +39,13 @@
39
39
  "provenance": true
40
40
  },
41
41
  "dependencies": {
42
- "@pracht/adapter-node": "^0.5.1",
43
- "@pracht/core": "^0.20.0"
42
+ "@pracht/adapter-node": "^1.0.0"
43
+ },
44
+ "peerDependencies": {
45
+ "@pracht/core": "^1.0.0"
46
+ },
47
+ "devDependencies": {
48
+ "@pracht/core": "^1.0.0"
44
49
  },
45
50
  "scripts": {
46
51
  "build": "tsdown"