appstage 0.2.10 → 0.2.12

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/bin.js CHANGED
@@ -460,7 +460,7 @@ async function buildClient({ clientDir, watch, watchClient }, plugins) {
460
460
  // src/scripts/utils/buildServer.ts
461
461
  import esbuild2 from "esbuild";
462
462
 
463
- // src/scripts/utils/populateEntries.ts
463
+ // src/scripts/utils/setEntriesExport.ts
464
464
  import { writeFile } from "node:fs/promises";
465
465
 
466
466
  // src/scripts/utils/toImportPath.ts
@@ -474,25 +474,24 @@ function toImportPath(relativePath, referencePath = ".") {
474
474
  return importPath;
475
475
  }
476
476
 
477
- // src/scripts/utils/populateEntries.ts
478
- async function populateEntries({ entriesPath }) {
477
+ // src/scripts/utils/setEntriesExport.ts
478
+ async function setEntriesExport({ entriesPath }) {
479
479
  if (entriesPath === null) return;
480
480
  let serverEntries = await getEntryPoints(["server", "server/index"]);
481
481
  let content = "";
482
482
  if (serverEntries.length === 0) content = "export const entries = [];";
483
483
  else {
484
484
  content = "export const entries = (\n await Promise.all([";
485
- for (let i = 0; i < serverEntries.length; i++) {
485
+ for (let i = 0; i < serverEntries.length; i++)
486
486
  content += `
487
- // ${serverEntries[i].name}
488
487
  import("${toImportPath(serverEntries[i].path, "src/server")}"),`;
489
- }
490
488
  content += "\n ])\n).map(({ server }) => server);";
491
489
  }
492
490
  await writeFile(
493
491
  entriesPath ?? "src/server/entries.ts",
494
492
  `// Populated automatically during the build phase by picking
495
- // all server exports from "src/entries/<entry_name>/server(/index)?.(js|ts)"
493
+ // all server exports from "src/entries/<entry_name>/server(/index)?.(js|ts)".
494
+ // Ignore this file if a custom set of entry exports is required.
496
495
  ${content}
497
496
  `
498
497
  );
@@ -502,7 +501,7 @@ ${content}
502
501
  var appServerEntryPoints = ["src/server/index.ts"];
503
502
  async function buildServer(params, plugins) {
504
503
  let { serverDir, watch, watchServer } = params;
505
- await populateEntries(params);
504
+ await setEntriesExport(params);
506
505
  let buildOptions = {
507
506
  ...commonBuildOptions,
508
507
  entryPoints: appServerEntryPoints,
package/dist/index.cjs CHANGED
@@ -44,86 +44,6 @@ function emitLog(app, message, payload) {
44
44
  return app.events?.emit("log", normalizedPayload);
45
45
  }
46
46
 
47
- function toLanguage(locale) {
48
- return locale.split(/[-_]/)[0];
49
- }
50
-
51
- async function resolveFilePath({ name, dir = ".", lang, supportedLocales = [], ext, index }) {
52
- let cwd = process.cwd();
53
- let localeSet = new Set(supportedLocales);
54
- let langSet = new Set(supportedLocales.map(toLanguage));
55
- let availableNames = [name, ...[...localeSet, ...langSet].map((item) => `${name}.${item}`)];
56
- let preferredLangNames;
57
- if (lang && (!supportedLocales.length || localeSet.has(lang) || langSet.has(lang))) preferredLangNames = [`${name}.${lang}`, `${name}.${toLanguage(lang)}`];
58
- let names = new Set(preferredLangNames ? [...preferredLangNames, ...availableNames] : availableNames);
59
- let exts = Array.isArray(ext) ? ext : [ext];
60
- for (let item of names) for (let itemExt of exts) {
61
- let path = (0, node_path.join)(cwd, dir, `${item}${itemExt ? `.${itemExt}` : ""}`);
62
- try {
63
- await (0, node_fs_promises.access)(path);
64
- return path;
65
- } catch {}
66
- }
67
- if (index) for (let item of names) for (let itemExt of exts) {
68
- let path = (0, node_path.join)(cwd, dir, item, `index${itemExt ? `.${itemExt}` : ""}`);
69
- try {
70
- await (0, node_fs_promises.access)(path);
71
- return path;
72
- } catch {}
73
- }
74
- }
75
-
76
- const defaultExt = ["html", "htm"];
77
- const defaultName = (req) => req.path.split("/").at(-1);
78
- /**
79
- * Serves files from the specified directory path in a locale-aware
80
- * fashion after applying optional transforms.
81
- *
82
- * A file ending with `.<lang>.<ext>` is picked first if the `<lang>`
83
- * part matches `req.ctx.lang`. If the `supportedLocales` array is
84
- * provided, the `*.<lang>.<ext>` file is picked only if the given
85
- * array contains `req.ctx.lang`. Otherwise, a file without the locale
86
- * in its path (`*.<ext>`) is picked.
87
- */
88
- const dir = ({ path, name = defaultName, ext = defaultExt, transform, supportedLocales, index = true }) => {
89
- if (typeof path !== "string") throw new Error(`'path' is not a string`);
90
- let transformSet = (Array.isArray(transform) ? transform : [transform]).filter((item) => typeof item === "function");
91
- return async (req, res) => {
92
- let fileName = typeof name === "function" ? name(req, res) : name;
93
- emitLog(req.app, `Name: ${JSON.stringify(fileName)}`, {
94
- req,
95
- res
96
- });
97
- if (fileName === void 0) {
98
- res.status(404).send(await req.app.renderStatus?.(req, res));
99
- return;
100
- }
101
- let filePath = await resolveFilePath({
102
- name: fileName,
103
- dir: path,
104
- ext,
105
- supportedLocales,
106
- lang: req.ctx?.lang,
107
- index
108
- });
109
- emitLog(req.app, `Path: ${JSON.stringify(filePath)}`, {
110
- req,
111
- res
112
- });
113
- if (!filePath) {
114
- res.status(404).send(await req.app.renderStatus?.(req, res));
115
- return;
116
- }
117
- let content = (await (0, node_fs_promises.readFile)(filePath)).toString();
118
- for (let transformItem of transformSet) content = await transformItem(req, res, {
119
- content,
120
- path: filePath,
121
- name: (0, node_path.basename)(filePath, (0, node_path.extname)(filePath))
122
- });
123
- res.send(content);
124
- };
125
- };
126
-
127
47
  const maxLanguages = 3;
128
48
  async function resolve(...parts) {
129
49
  let fullPath = (0, node_path.join)(...parts);
@@ -148,6 +68,15 @@ function getLanguageList(req) {
148
68
  }
149
69
  return Array.from(langs);
150
70
  }
71
+ function matches(x, matcher) {
72
+ if (matcher === null || matcher === void 0) return true;
73
+ if (typeof matcher === "function") return matcher(x);
74
+ let patterns = Array.isArray(matcher) ? matcher : [matcher];
75
+ for (let pattern of patterns) if (pattern instanceof RegExp) {
76
+ if (pattern.test(x)) return true;
77
+ } else if (pattern === x) return true;
78
+ return false;
79
+ }
151
80
  const defaultExtensions = ["html", "htm"];
152
81
  const defaultPath = (req) => req.originalUrl.split("?")[0];
153
82
  const defaultLanguages = getLanguageList;
@@ -162,6 +91,14 @@ const files = (params) => {
162
91
  return async (req, res) => {
163
92
  let langs = (p.languages ?? defaultLanguages)(req);
164
93
  let path = typeof p.path === "string" ? p.path : (p.path ?? defaultPath)(req);
94
+ if (!matches(path, p.matches)) {
95
+ emitLog(req.app, "Unmatched path", { data: { path } });
96
+ res.status(404).send(await req.app.renderStatus?.(req, res, {
97
+ code: "unmatched_path",
98
+ path
99
+ }));
100
+ return;
101
+ }
165
102
  if (path.includes("../")) {
166
103
  emitLog(req.app, "Invalid path (potential traversal attempt)", { data: { path } });
167
104
  res.status(400).send(await req.app.renderStatus?.(req, res, {
@@ -173,10 +110,12 @@ const files = (params) => {
173
110
  let filePath = null;
174
111
  for (let k = 0; k < bases.length && filePath === null; k++) {
175
112
  let base = bases[k];
176
- for (let i = 0; i < langs.length && filePath === null; i++) filePath = await resolve(base, `${path}.${langs[i]}`);
177
- if (filePath === null) filePath = await resolve(base, path);
178
- for (let i = 0; i < langs.length && filePath === null; i++) for (let j = 0; j < exts.length && filePath === null; j++) filePath = await resolve(base, `${path}.${langs[i]}.${exts[j]}`);
179
- for (let i = 0; i < exts.length && filePath === null; i++) filePath = await resolve(base, `${path}.${exts[i]}`);
113
+ if (!path.endsWith("/")) {
114
+ for (let i = 0; i < langs.length && filePath === null; i++) filePath = await resolve(base, `${path}.${langs[i]}`);
115
+ if (filePath === null) filePath = await resolve(base, path);
116
+ for (let i = 0; i < langs.length && filePath === null; i++) for (let j = 0; j < exts.length && filePath === null; j++) filePath = await resolve(base, `${path}.${langs[i]}.${exts[j]}`);
117
+ for (let i = 0; i < exts.length && filePath === null; i++) filePath = await resolve(base, `${path}.${exts[i]}`);
118
+ }
180
119
  for (let i = 0; i < langs.length && filePath === null; i++) for (let j = 0; j < exts.length && filePath === null; j++) filePath = await resolve(base, `${path}.${langs[i]}`, `index.${exts[j]}`);
181
120
  for (let i = 0; i < langs.length && filePath === null; i++) for (let j = 0; j < exts.length && filePath === null; j++) filePath = await resolve(base, path, `index.${langs[i]}.${exts[j]}`);
182
121
  for (let i = 0; i < exts.length && filePath === null; i++) filePath = await resolve(base, path, `index.${exts[i]}`);
@@ -194,16 +133,17 @@ const files = (params) => {
194
133
  return;
195
134
  }
196
135
  let content = (await (0, node_fs_promises.readFile)(filePath)).toString();
197
- let name = (0, node_path.basename)(filePath);
136
+ let ext = (0, node_path.extname)(filePath);
137
+ let name = (0, node_path.basename)(filePath, ext);
198
138
  for (let transform of p.transform) {
199
139
  let result = transform(req, res, {
200
140
  content,
201
- path,
141
+ path: filePath,
202
142
  name
203
143
  });
204
144
  content = result instanceof Promise ? await result : result;
205
145
  }
206
- res.type((0, node_path.extname)(name).slice(1)).send(content);
146
+ res.type(ext.slice(1)).send(content);
207
147
  };
208
148
  };
209
149
 
@@ -226,6 +166,10 @@ const unhandledRoute = () => async (req, res) => {
226
166
  res.status(404).send(await req.app.renderStatus?.(req, res, "unhandled_route"));
227
167
  };
228
168
 
169
+ function toLanguage(locale) {
170
+ return locale.split(/[-_]/)[0];
171
+ }
172
+
229
173
  function getEffectiveLocale(preferredLocales, supportedLocales) {
230
174
  if (!supportedLocales || supportedLocales.length === 0) return void 0;
231
175
  if (!preferredLocales || preferredLocales.length === 0) return supportedLocales[0];
@@ -485,19 +429,19 @@ function toImportPath(relativePath, referencePath = ".") {
485
429
  return importPath;
486
430
  }
487
431
 
488
- async function populateEntries({ entriesPath }) {
432
+ async function setEntriesExport({ entriesPath }) {
489
433
  if (entriesPath === null) return;
490
434
  let serverEntries = await getEntryPoints(["server", "server/index"]);
491
435
  let content = "";
492
436
  if (serverEntries.length === 0) content = "export const entries = [];";
493
437
  else {
494
438
  content = "export const entries = (\n await Promise.all([";
495
- for (let i = 0; i < serverEntries.length; i++) content += `\n // ${serverEntries[i].name}
496
- import("${toImportPath(serverEntries[i].path, "src/server")}"),`;
439
+ for (let i = 0; i < serverEntries.length; i++) content += `\n import("${toImportPath(serverEntries[i].path, "src/server")}"),`;
497
440
  content += "\n ])\n).map(({ server }) => server);";
498
441
  }
499
442
  await (0, node_fs_promises.writeFile)(entriesPath ?? "src/server/entries.ts", `// Populated automatically during the build phase by picking
500
- // all server exports from "src/entries/<entry_name>/server(/index)?.(js|ts)"
443
+ // all server exports from "src/entries/<entry_name>/server(/index)?.(js|ts)".
444
+ // Ignore this file if a custom set of entry exports is required.
501
445
  ${content}
502
446
  `);
503
447
  }
@@ -505,7 +449,7 @@ ${content}
505
449
  const appServerEntryPoints = ["src/server/index.ts"];
506
450
  async function buildServer(params, plugins) {
507
451
  let { serverDir, watch, watchServer } = params;
508
- await populateEntries(params);
452
+ await setEntriesExport(params);
509
453
  let buildOptions = {
510
454
  ...commonBuildOptions,
511
455
  entryPoints: appServerEntryPoints,
@@ -720,7 +664,6 @@ exports.build = build;
720
664
  exports.cli = cli;
721
665
  exports.createApp = createApp;
722
666
  exports.cspNonce = cspNonce;
723
- exports.dir = dir;
724
667
  exports.emitLog = emitLog;
725
668
  exports.files = files;
726
669
  exports.getEffectiveLocale = getEffectiveLocale;
@@ -732,7 +675,6 @@ exports.lang = lang;
732
675
  exports.log = log;
733
676
  exports.renderStatus = renderStatus;
734
677
  exports.requestEvents = requestEvents;
735
- exports.resolveFilePath = resolveFilePath;
736
678
  exports.serializeState = serializeState;
737
679
  exports.servePipeableStream = servePipeableStream;
738
680
  exports.toLanguage = toLanguage;
package/dist/index.d.ts CHANGED
@@ -16,68 +16,11 @@ type TransformContent = (req: Request, res: Response, params: {
16
16
  name?: string;
17
17
  }) => string | Promise<string>;
18
18
 
19
- type ResolveFilePathParams = {
20
- name: string;
21
- dir?: string;
22
- lang?: string;
23
- supportedLocales?: string[]; /** Allowed file extensions. */
24
- ext?: string | string[];
25
- /**
26
- * Whether an index file should be checked if the resolved file name
27
- * doesn't correspond to an existing file.
28
- *
29
- * @defaultValue `true`
30
- */
31
- index?: boolean;
32
- };
33
- declare function resolveFilePath({
34
- name,
35
- dir,
36
- lang,
37
- supportedLocales,
38
- ext,
39
- index
40
- }: ResolveFilePathParams): Promise<string | undefined>;
41
-
42
- type ZeroTransform = false | null | undefined;
43
- type DirParams = Partial<Pick<ResolveFilePathParams, "supportedLocales" | "index">> & {
44
- /** Directory path to serve files from. */path: string;
45
- /**
46
- * File name.
47
- * By default, the portion of `req.path` after the last slash.
48
- */
49
- name?: string | undefined | ((req: Request, res: Response) => string | undefined);
50
- /**
51
- * Allowed file extensions.
52
- *
53
- * @defaultValue `['html', 'htm']`
54
- */
55
- ext?: ResolveFilePathParams["ext"];
56
- /**
57
- * Custom transforms applied to the file content.
58
- *
59
- * Example: Use `injectNonce` from this package to inject the `nonce`
60
- * value generated for the current request into the `{{nonce}}`
61
- * placeholders in an HTML file.
62
- */
63
- transform?: TransformContent | ZeroTransform | (TransformContent | ZeroTransform)[];
64
- supportedLocales?: string[];
65
- };
66
- /**
67
- * Serves files from the specified directory path in a locale-aware
68
- * fashion after applying optional transforms.
69
- *
70
- * A file ending with `.<lang>.<ext>` is picked first if the `<lang>`
71
- * part matches `req.ctx.lang`. If the `supportedLocales` array is
72
- * provided, the `*.<lang>.<ext>` file is picked only if the given
73
- * array contains `req.ctx.lang`. Otherwise, a file without the locale
74
- * in its path (`*.<ext>`) is picked.
75
- */
76
- declare const dir$1: Controller<DirParams>;
77
-
19
+ type StringMatcher = string | RegExp | (string | RegExp)[] | ((x: string) => boolean) | null;
78
20
  type FilesParams = {
79
21
  base: string | string[];
80
- path?: string | ((req: Request) => string);
22
+ path?: string | ((req: Request) => string); /** Specifies which paths should be accepted. */
23
+ matches?: StringMatcher;
81
24
  extensions?: string[];
82
25
  languages?: (req: Request) => string[];
83
26
  transform?: TransformContent[];
@@ -136,7 +79,7 @@ type LangParams = {
136
79
  shouldRedirect?: boolean;
137
80
  langCookieOptions?: CookieOptions;
138
81
  };
139
- declare const lang$1: Middleware<LangParams | void>;
82
+ declare const lang: Middleware<LangParams | void>;
140
83
 
141
84
  /**
142
85
  * Adds event handlers, like logging, to essential request phases.
@@ -1179,4 +1122,4 @@ declare function servePipeableStream(req: Request, res: Response): ({
1179
1122
  pipe
1180
1123
  }: PipeableStream, error?: unknown) => Promise<void>;
1181
1124
 
1182
- export { Controller, DirParams, ErrorController, FilesParams, LangParams, LogEventPayload, LogLevel, LogOptions, Middleware, MiddlewareSet, RenderStatus, ReqCtx, ResolveFilePathParams, TransformContent, build, cli, createApp, cspNonce, dir$1 as dir, emitLog, files, getEffectiveLocale, getLocales, getStatusMessage, init, injectNonce, lang$1 as lang, log, renderStatus, requestEvents, resolveFilePath, serializeState, servePipeableStream, toLanguage, unhandledError, unhandledRoute };
1125
+ export { Controller, ErrorController, FilesParams, LangParams, LogEventPayload, LogLevel, LogOptions, Middleware, MiddlewareSet, RenderStatus, ReqCtx, TransformContent, build, cli, createApp, cspNonce, emitLog, files, getEffectiveLocale, getLocales, getStatusMessage, init, injectNonce, lang, log, renderStatus, requestEvents, serializeState, servePipeableStream, toLanguage, unhandledError, unhandledRoute };
package/dist/index.mjs CHANGED
@@ -18,86 +18,6 @@ function emitLog(app, message, payload) {
18
18
  return app.events?.emit("log", normalizedPayload);
19
19
  }
20
20
 
21
- function toLanguage(locale) {
22
- return locale.split(/[-_]/)[0];
23
- }
24
-
25
- async function resolveFilePath({ name, dir = ".", lang, supportedLocales = [], ext, index }) {
26
- let cwd = process.cwd();
27
- let localeSet = new Set(supportedLocales);
28
- let langSet = new Set(supportedLocales.map(toLanguage));
29
- let availableNames = [name, ...[...localeSet, ...langSet].map((item) => `${name}.${item}`)];
30
- let preferredLangNames;
31
- if (lang && (!supportedLocales.length || localeSet.has(lang) || langSet.has(lang))) preferredLangNames = [`${name}.${lang}`, `${name}.${toLanguage(lang)}`];
32
- let names = new Set(preferredLangNames ? [...preferredLangNames, ...availableNames] : availableNames);
33
- let exts = Array.isArray(ext) ? ext : [ext];
34
- for (let item of names) for (let itemExt of exts) {
35
- let path = join(cwd, dir, `${item}${itemExt ? `.${itemExt}` : ""}`);
36
- try {
37
- await access(path);
38
- return path;
39
- } catch {}
40
- }
41
- if (index) for (let item of names) for (let itemExt of exts) {
42
- let path = join(cwd, dir, item, `index${itemExt ? `.${itemExt}` : ""}`);
43
- try {
44
- await access(path);
45
- return path;
46
- } catch {}
47
- }
48
- }
49
-
50
- const defaultExt = ["html", "htm"];
51
- const defaultName = (req) => req.path.split("/").at(-1);
52
- /**
53
- * Serves files from the specified directory path in a locale-aware
54
- * fashion after applying optional transforms.
55
- *
56
- * A file ending with `.<lang>.<ext>` is picked first if the `<lang>`
57
- * part matches `req.ctx.lang`. If the `supportedLocales` array is
58
- * provided, the `*.<lang>.<ext>` file is picked only if the given
59
- * array contains `req.ctx.lang`. Otherwise, a file without the locale
60
- * in its path (`*.<ext>`) is picked.
61
- */
62
- const dir = ({ path, name = defaultName, ext = defaultExt, transform, supportedLocales, index = true }) => {
63
- if (typeof path !== "string") throw new Error(`'path' is not a string`);
64
- let transformSet = (Array.isArray(transform) ? transform : [transform]).filter((item) => typeof item === "function");
65
- return async (req, res) => {
66
- let fileName = typeof name === "function" ? name(req, res) : name;
67
- emitLog(req.app, `Name: ${JSON.stringify(fileName)}`, {
68
- req,
69
- res
70
- });
71
- if (fileName === void 0) {
72
- res.status(404).send(await req.app.renderStatus?.(req, res));
73
- return;
74
- }
75
- let filePath = await resolveFilePath({
76
- name: fileName,
77
- dir: path,
78
- ext,
79
- supportedLocales,
80
- lang: req.ctx?.lang,
81
- index
82
- });
83
- emitLog(req.app, `Path: ${JSON.stringify(filePath)}`, {
84
- req,
85
- res
86
- });
87
- if (!filePath) {
88
- res.status(404).send(await req.app.renderStatus?.(req, res));
89
- return;
90
- }
91
- let content = (await readFile(filePath)).toString();
92
- for (let transformItem of transformSet) content = await transformItem(req, res, {
93
- content,
94
- path: filePath,
95
- name: basename(filePath, extname(filePath))
96
- });
97
- res.send(content);
98
- };
99
- };
100
-
101
21
  const maxLanguages = 3;
102
22
  async function resolve(...parts) {
103
23
  let fullPath = join(...parts);
@@ -122,6 +42,15 @@ function getLanguageList(req) {
122
42
  }
123
43
  return Array.from(langs);
124
44
  }
45
+ function matches(x, matcher) {
46
+ if (matcher === null || matcher === void 0) return true;
47
+ if (typeof matcher === "function") return matcher(x);
48
+ let patterns = Array.isArray(matcher) ? matcher : [matcher];
49
+ for (let pattern of patterns) if (pattern instanceof RegExp) {
50
+ if (pattern.test(x)) return true;
51
+ } else if (pattern === x) return true;
52
+ return false;
53
+ }
125
54
  const defaultExtensions = ["html", "htm"];
126
55
  const defaultPath = (req) => req.originalUrl.split("?")[0];
127
56
  const defaultLanguages = getLanguageList;
@@ -136,6 +65,14 @@ const files = (params) => {
136
65
  return async (req, res) => {
137
66
  let langs = (p.languages ?? defaultLanguages)(req);
138
67
  let path = typeof p.path === "string" ? p.path : (p.path ?? defaultPath)(req);
68
+ if (!matches(path, p.matches)) {
69
+ emitLog(req.app, "Unmatched path", { data: { path } });
70
+ res.status(404).send(await req.app.renderStatus?.(req, res, {
71
+ code: "unmatched_path",
72
+ path
73
+ }));
74
+ return;
75
+ }
139
76
  if (path.includes("../")) {
140
77
  emitLog(req.app, "Invalid path (potential traversal attempt)", { data: { path } });
141
78
  res.status(400).send(await req.app.renderStatus?.(req, res, {
@@ -147,10 +84,12 @@ const files = (params) => {
147
84
  let filePath = null;
148
85
  for (let k = 0; k < bases.length && filePath === null; k++) {
149
86
  let base = bases[k];
150
- for (let i = 0; i < langs.length && filePath === null; i++) filePath = await resolve(base, `${path}.${langs[i]}`);
151
- if (filePath === null) filePath = await resolve(base, path);
152
- for (let i = 0; i < langs.length && filePath === null; i++) for (let j = 0; j < exts.length && filePath === null; j++) filePath = await resolve(base, `${path}.${langs[i]}.${exts[j]}`);
153
- for (let i = 0; i < exts.length && filePath === null; i++) filePath = await resolve(base, `${path}.${exts[i]}`);
87
+ if (!path.endsWith("/")) {
88
+ for (let i = 0; i < langs.length && filePath === null; i++) filePath = await resolve(base, `${path}.${langs[i]}`);
89
+ if (filePath === null) filePath = await resolve(base, path);
90
+ for (let i = 0; i < langs.length && filePath === null; i++) for (let j = 0; j < exts.length && filePath === null; j++) filePath = await resolve(base, `${path}.${langs[i]}.${exts[j]}`);
91
+ for (let i = 0; i < exts.length && filePath === null; i++) filePath = await resolve(base, `${path}.${exts[i]}`);
92
+ }
154
93
  for (let i = 0; i < langs.length && filePath === null; i++) for (let j = 0; j < exts.length && filePath === null; j++) filePath = await resolve(base, `${path}.${langs[i]}`, `index.${exts[j]}`);
155
94
  for (let i = 0; i < langs.length && filePath === null; i++) for (let j = 0; j < exts.length && filePath === null; j++) filePath = await resolve(base, path, `index.${langs[i]}.${exts[j]}`);
156
95
  for (let i = 0; i < exts.length && filePath === null; i++) filePath = await resolve(base, path, `index.${exts[i]}`);
@@ -168,16 +107,17 @@ const files = (params) => {
168
107
  return;
169
108
  }
170
109
  let content = (await readFile(filePath)).toString();
171
- let name = basename(filePath);
110
+ let ext = extname(filePath);
111
+ let name = basename(filePath, ext);
172
112
  for (let transform of p.transform) {
173
113
  let result = transform(req, res, {
174
114
  content,
175
- path,
115
+ path: filePath,
176
116
  name
177
117
  });
178
118
  content = result instanceof Promise ? await result : result;
179
119
  }
180
- res.type(extname(name).slice(1)).send(content);
120
+ res.type(ext.slice(1)).send(content);
181
121
  };
182
122
  };
183
123
 
@@ -200,6 +140,10 @@ const unhandledRoute = () => async (req, res) => {
200
140
  res.status(404).send(await req.app.renderStatus?.(req, res, "unhandled_route"));
201
141
  };
202
142
 
143
+ function toLanguage(locale) {
144
+ return locale.split(/[-_]/)[0];
145
+ }
146
+
203
147
  function getEffectiveLocale(preferredLocales, supportedLocales) {
204
148
  if (!supportedLocales || supportedLocales.length === 0) return void 0;
205
149
  if (!preferredLocales || preferredLocales.length === 0) return supportedLocales[0];
@@ -459,19 +403,19 @@ function toImportPath(relativePath, referencePath = ".") {
459
403
  return importPath;
460
404
  }
461
405
 
462
- async function populateEntries({ entriesPath }) {
406
+ async function setEntriesExport({ entriesPath }) {
463
407
  if (entriesPath === null) return;
464
408
  let serverEntries = await getEntryPoints(["server", "server/index"]);
465
409
  let content = "";
466
410
  if (serverEntries.length === 0) content = "export const entries = [];";
467
411
  else {
468
412
  content = "export const entries = (\n await Promise.all([";
469
- for (let i = 0; i < serverEntries.length; i++) content += `\n // ${serverEntries[i].name}
470
- import("${toImportPath(serverEntries[i].path, "src/server")}"),`;
413
+ for (let i = 0; i < serverEntries.length; i++) content += `\n import("${toImportPath(serverEntries[i].path, "src/server")}"),`;
471
414
  content += "\n ])\n).map(({ server }) => server);";
472
415
  }
473
416
  await writeFile(entriesPath ?? "src/server/entries.ts", `// Populated automatically during the build phase by picking
474
- // all server exports from "src/entries/<entry_name>/server(/index)?.(js|ts)"
417
+ // all server exports from "src/entries/<entry_name>/server(/index)?.(js|ts)".
418
+ // Ignore this file if a custom set of entry exports is required.
475
419
  ${content}
476
420
  `);
477
421
  }
@@ -479,7 +423,7 @@ ${content}
479
423
  const appServerEntryPoints = ["src/server/index.ts"];
480
424
  async function buildServer(params, plugins) {
481
425
  let { serverDir, watch, watchServer } = params;
482
- await populateEntries(params);
426
+ await setEntriesExport(params);
483
427
  let buildOptions = {
484
428
  ...commonBuildOptions,
485
429
  entryPoints: appServerEntryPoints,
@@ -690,4 +634,4 @@ function servePipeableStream(req, res) {
690
634
  };
691
635
  }
692
636
 
693
- export { build, cli, createApp, cspNonce, dir, emitLog, files, getEffectiveLocale, getLocales, getStatusMessage, init, injectNonce, lang, log, renderStatus, requestEvents, resolveFilePath, serializeState, servePipeableStream, toLanguage, unhandledError, unhandledRoute };
637
+ export { build, cli, createApp, cspNonce, emitLog, files, getEffectiveLocale, getLocales, getStatusMessage, init, injectNonce, lang, log, renderStatus, requestEvents, serializeState, servePipeableStream, toLanguage, unhandledError, unhandledRoute };
package/index.ts CHANGED
@@ -1,4 +1,3 @@
1
- export * from "./src/controllers/dir.ts";
2
1
  export * from "./src/controllers/files.ts";
3
2
  export * from "./src/controllers/unhandledError.ts";
4
3
  export * from "./src/controllers/unhandledRoute.ts";
@@ -27,6 +26,5 @@ export * from "./src/utils/emitLog.ts";
27
26
  export * from "./src/utils/getStatusMessage.ts";
28
27
  export * from "./src/utils/injectNonce.ts";
29
28
  export * from "./src/utils/renderStatus.ts";
30
- export * from "./src/utils/resolveFilePath.ts";
31
29
  export * from "./src/utils/serializeState.ts";
32
30
  export * from "./src/utils/servePipeableStream.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appstage",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.mjs",
@@ -5,6 +5,13 @@ import type { Controller } from "../types/Controller.ts";
5
5
  import type { TransformContent } from "../types/TransformContent.ts";
6
6
  import { emitLog } from "../utils/emitLog.ts";
7
7
 
8
+ type StringMatcher =
9
+ | string
10
+ | RegExp
11
+ | (string | RegExp)[]
12
+ | ((x: string) => boolean)
13
+ | null;
14
+
8
15
  const maxLanguages = 3;
9
16
 
10
17
  async function resolve(...parts: string[]) {
@@ -38,9 +45,27 @@ function getLanguageList(req: Request) {
38
45
  return Array.from(langs);
39
46
  }
40
47
 
48
+ function matches(x: string, matcher: StringMatcher | undefined) {
49
+ if (matcher === null || matcher === undefined) return true;
50
+
51
+ if (typeof matcher === "function") return matcher(x);
52
+
53
+ let patterns = Array.isArray(matcher) ? matcher : [matcher];
54
+
55
+ for (let pattern of patterns) {
56
+ if (pattern instanceof RegExp) {
57
+ if (pattern.test(x)) return true;
58
+ } else if (pattern === x) return true;
59
+ }
60
+
61
+ return false;
62
+ }
63
+
41
64
  export type FilesParams = {
42
65
  base: string | string[];
43
66
  path?: string | ((req: Request) => string);
67
+ /** Specifies which paths should be accepted. */
68
+ matches?: StringMatcher;
44
69
  extensions?: string[];
45
70
  languages?: (req: Request) => string[];
46
71
  transform?: TransformContent[];
@@ -66,6 +91,19 @@ export const files: Controller<string | FilesParams> = (params) => {
66
91
  let path =
67
92
  typeof p.path === "string" ? p.path : (p.path ?? defaultPath)(req);
68
93
 
94
+ if (!matches(path, p.matches)) {
95
+ emitLog(req.app, "Unmatched path", { data: { path } });
96
+
97
+ res.status(404).send(
98
+ await req.app.renderStatus?.(req, res, {
99
+ code: "unmatched_path",
100
+ path,
101
+ }),
102
+ );
103
+
104
+ return;
105
+ }
106
+
69
107
  if (path.includes("../")) {
70
108
  emitLog(req.app, "Invalid path (potential traversal attempt)", {
71
109
  data: { path },
@@ -81,29 +119,31 @@ export const files: Controller<string | FilesParams> = (params) => {
81
119
  return;
82
120
  }
83
121
 
84
- // path: /x
85
- // langs: en, ru
86
122
  let filePath: string | null = null;
87
123
 
124
+ // path: /x
125
+ // langs: en, ru
88
126
  for (let k = 0; k < bases.length && filePath === null; k++) {
89
127
  let base = bases[k];
90
128
 
91
- // /x.en /x.ru
92
- for (let i = 0; i < langs.length && filePath === null; i++)
93
- filePath = await resolve(base, `${path}.${langs[i]}`);
129
+ if (!path.endsWith("/")) {
130
+ // /x.en /x.ru
131
+ for (let i = 0; i < langs.length && filePath === null; i++)
132
+ filePath = await resolve(base, `${path}.${langs[i]}`);
94
133
 
95
- // /x
96
- if (filePath === null) filePath = await resolve(base, path);
134
+ // /x
135
+ if (filePath === null) filePath = await resolve(base, path);
97
136
 
98
- // /x.en.html /x.en.htm /x.ru.html /x.ru.htm
99
- for (let i = 0; i < langs.length && filePath === null; i++) {
100
- for (let j = 0; j < exts.length && filePath === null; j++)
101
- filePath = await resolve(base, `${path}.${langs[i]}.${exts[j]}`);
102
- }
137
+ // /x.en.html /x.en.htm /x.ru.html /x.ru.htm
138
+ for (let i = 0; i < langs.length && filePath === null; i++) {
139
+ for (let j = 0; j < exts.length && filePath === null; j++)
140
+ filePath = await resolve(base, `${path}.${langs[i]}.${exts[j]}`);
141
+ }
103
142
 
104
- // /x.html /x.htm
105
- for (let i = 0; i < exts.length && filePath === null; i++)
106
- filePath = await resolve(base, `${path}.${exts[i]}`);
143
+ // /x.html /x.htm
144
+ for (let i = 0; i < exts.length && filePath === null; i++)
145
+ filePath = await resolve(base, `${path}.${exts[i]}`);
146
+ }
107
147
 
108
148
  // /x.en/index.html /x.en/index.htm /x.ru/index.html /x.ru/index.htm
109
149
  for (let i = 0; i < langs.length && filePath === null; i++) {
@@ -145,14 +185,15 @@ export const files: Controller<string | FilesParams> = (params) => {
145
185
  }
146
186
 
147
187
  let content = (await readFile(filePath)).toString();
148
- let name = basename(filePath);
188
+ let ext = extname(filePath);
189
+ let name = basename(filePath, ext);
149
190
 
150
191
  for (let transform of p.transform) {
151
- let result = transform(req, res, { content, path, name });
192
+ let result = transform(req, res, { content, path: filePath, name });
152
193
 
153
194
  content = result instanceof Promise ? await result : result;
154
195
  }
155
196
 
156
- res.type(extname(name).slice(1)).send(content);
197
+ res.type(ext.slice(1)).send(content);
157
198
  };
158
199
  };
@@ -1,14 +1,14 @@
1
1
  import esbuild, { type BuildOptions, type Plugin } from "esbuild";
2
2
  import { commonBuildOptions } from "../const/commonBuildOptions.ts";
3
3
  import type { BuildParams } from "../types/BuildParams.ts";
4
- import { populateEntries } from "./populateEntries.ts";
4
+ import { setEntriesExport } from "./setEntriesExport.ts";
5
5
 
6
6
  const appServerEntryPoints = ["src/server/index.ts"];
7
7
 
8
8
  export async function buildServer(params: BuildParams, plugins?: Plugin[]) {
9
9
  let { serverDir, watch, watchServer } = params;
10
10
 
11
- await populateEntries(params);
11
+ await setEntriesExport(params);
12
12
 
13
13
  let buildOptions: BuildOptions = {
14
14
  ...commonBuildOptions,
@@ -3,7 +3,7 @@ import type { BuildParams } from "../types/BuildParams.ts";
3
3
  import { getEntryPoints } from "./getEntryPoints.ts";
4
4
  import { toImportPath } from "./toImportPath.ts";
5
5
 
6
- export async function populateEntries({ entriesPath }: BuildParams) {
6
+ export async function setEntriesExport({ entriesPath }: BuildParams) {
7
7
  if (entriesPath === null) return;
8
8
 
9
9
  let serverEntries = await getEntryPoints(["server", "server/index"]);
@@ -13,10 +13,8 @@ export async function populateEntries({ entriesPath }: BuildParams) {
13
13
  else {
14
14
  content = "export const entries = (\n await Promise.all([";
15
15
 
16
- for (let i = 0; i < serverEntries.length; i++) {
17
- content += `\n // ${serverEntries[i].name}
18
- import("${toImportPath(serverEntries[i].path, "src/server")}"),`;
19
- }
16
+ for (let i = 0; i < serverEntries.length; i++)
17
+ content += `\n import("${toImportPath(serverEntries[i].path, "src/server")}"),`;
20
18
 
21
19
  content += "\n ])\n).map(({ server }) => server);";
22
20
  }
@@ -24,7 +22,8 @@ export async function populateEntries({ entriesPath }: BuildParams) {
24
22
  await writeFile(
25
23
  entriesPath ?? "src/server/entries.ts",
26
24
  `// Populated automatically during the build phase by picking
27
- // all server exports from "src/entries/<entry_name>/server(/index)?.(js|ts)"
25
+ // all server exports from "src/entries/<entry_name>/server(/index)?.(js|ts)".
26
+ // Ignore this file if a custom set of entry exports is required.
28
27
  ${content}
29
28
  `,
30
29
  );
@@ -1,119 +0,0 @@
1
- import { readFile } from "node:fs/promises";
2
- import { basename, extname } from "node:path";
3
- import type { Request, Response } from "express";
4
- import type { Controller } from "../types/Controller.ts";
5
- import type { TransformContent } from "../types/TransformContent.ts";
6
- import { emitLog } from "../utils/emitLog.ts";
7
- import {
8
- type ResolveFilePathParams,
9
- resolveFilePath,
10
- } from "../utils/resolveFilePath.ts";
11
-
12
- const defaultExt = ["html", "htm"];
13
- const defaultName = (req: Request) => req.path.split("/").at(-1);
14
-
15
- type ZeroTransform = false | null | undefined;
16
-
17
- export type DirParams = Partial<
18
- Pick<ResolveFilePathParams, "supportedLocales" | "index">
19
- > & {
20
- /** Directory path to serve files from. */
21
- path: string;
22
- /**
23
- * File name.
24
- * By default, the portion of `req.path` after the last slash.
25
- */
26
- name?:
27
- | string
28
- | undefined
29
- | ((req: Request, res: Response) => string | undefined);
30
- /**
31
- * Allowed file extensions.
32
- *
33
- * @defaultValue `['html', 'htm']`
34
- */
35
- ext?: ResolveFilePathParams["ext"];
36
- /**
37
- * Custom transforms applied to the file content.
38
- *
39
- * Example: Use `injectNonce` from this package to inject the `nonce`
40
- * value generated for the current request into the `{{nonce}}`
41
- * placeholders in an HTML file.
42
- */
43
- transform?:
44
- | TransformContent
45
- | ZeroTransform
46
- | (TransformContent | ZeroTransform)[];
47
- supportedLocales?: string[];
48
- };
49
-
50
- /**
51
- * Serves files from the specified directory path in a locale-aware
52
- * fashion after applying optional transforms.
53
- *
54
- * A file ending with `.<lang>.<ext>` is picked first if the `<lang>`
55
- * part matches `req.ctx.lang`. If the `supportedLocales` array is
56
- * provided, the `*.<lang>.<ext>` file is picked only if the given
57
- * array contains `req.ctx.lang`. Otherwise, a file without the locale
58
- * in its path (`*.<ext>`) is picked.
59
- */
60
- export const dir: Controller<DirParams> = ({
61
- path,
62
- name = defaultName,
63
- ext = defaultExt,
64
- transform,
65
- supportedLocales,
66
- index = true,
67
- }) => {
68
- if (typeof path !== "string") throw new Error(`'path' is not a string`);
69
-
70
- let transformSet = (
71
- Array.isArray(transform) ? transform : [transform]
72
- ).filter((item) => typeof item === "function");
73
-
74
- return async (req, res) => {
75
- let fileName = typeof name === "function" ? name(req, res) : name;
76
-
77
- emitLog(req.app, `Name: ${JSON.stringify(fileName)}`, {
78
- req,
79
- res,
80
- });
81
-
82
- if (fileName === undefined) {
83
- res.status(404).send(await req.app.renderStatus?.(req, res));
84
-
85
- return;
86
- }
87
-
88
- let filePath = await resolveFilePath({
89
- name: fileName,
90
- dir: path,
91
- ext,
92
- supportedLocales,
93
- lang: req.ctx?.lang,
94
- index,
95
- });
96
-
97
- emitLog(req.app, `Path: ${JSON.stringify(filePath)}`, {
98
- req,
99
- res,
100
- });
101
-
102
- if (!filePath) {
103
- res.status(404).send(await req.app.renderStatus?.(req, res));
104
-
105
- return;
106
- }
107
-
108
- let content = (await readFile(filePath)).toString();
109
-
110
- for (let transformItem of transformSet)
111
- content = await transformItem(req, res, {
112
- content,
113
- path: filePath,
114
- name: basename(filePath, extname(filePath)),
115
- });
116
-
117
- res.send(content);
118
- };
119
- };
@@ -1,78 +0,0 @@
1
- import { access } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { toLanguage } from "../lib/lang/toLanguage.ts";
4
-
5
- export type ResolveFilePathParams = {
6
- name: string;
7
- dir?: string;
8
- lang?: string;
9
- supportedLocales?: string[];
10
- /** Allowed file extensions. */
11
- ext?: string | string[];
12
- /**
13
- * Whether an index file should be checked if the resolved file name
14
- * doesn't correspond to an existing file.
15
- *
16
- * @defaultValue `true`
17
- */
18
- index?: boolean;
19
- };
20
-
21
- export async function resolveFilePath({
22
- name,
23
- dir = ".",
24
- lang,
25
- supportedLocales = [],
26
- ext,
27
- index,
28
- }: ResolveFilePathParams) {
29
- let cwd = process.cwd();
30
-
31
- let localeSet = new Set(supportedLocales);
32
- let langSet = new Set(supportedLocales.map(toLanguage));
33
-
34
- let availableNames = [
35
- name,
36
- ...[...localeSet, ...langSet].map((item) => `${name}.${item}`),
37
- ];
38
-
39
- let preferredLangNames: string[] | undefined;
40
-
41
- if (
42
- lang &&
43
- (!supportedLocales.length || localeSet.has(lang) || langSet.has(lang))
44
- )
45
- preferredLangNames = [`${name}.${lang}`, `${name}.${toLanguage(lang)}`];
46
-
47
- let names = new Set(
48
- preferredLangNames
49
- ? [...preferredLangNames, ...availableNames]
50
- : availableNames,
51
- );
52
-
53
- let exts = Array.isArray(ext) ? ext : [ext];
54
-
55
- for (let item of names) {
56
- for (let itemExt of exts) {
57
- let path = join(cwd, dir, `${item}${itemExt ? `.${itemExt}` : ""}`);
58
-
59
- try {
60
- await access(path);
61
- return path;
62
- } catch {}
63
- }
64
- }
65
-
66
- if (index) {
67
- for (let item of names) {
68
- for (let itemExt of exts) {
69
- let path = join(cwd, dir, item, `index${itemExt ? `.${itemExt}` : ""}`);
70
-
71
- try {
72
- await access(path);
73
- return path;
74
- } catch {}
75
- }
76
- }
77
- }
78
- }