appstage 0.2.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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +0 -0
  3. package/dist/bin/build.js +532 -0
  4. package/dist/bin/startDev.js +545 -0
  5. package/dist/bin/startProd.js +545 -0
  6. package/dist/index.cjs +671 -0
  7. package/dist/index.d.ts +1162 -0
  8. package/dist/index.mjs +622 -0
  9. package/index.ts +32 -0
  10. package/package.json +39 -0
  11. package/src/controllers/dir.ts +119 -0
  12. package/src/controllers/unhandledError.ts +15 -0
  13. package/src/controllers/unhandledRoute.ts +14 -0
  14. package/src/lib/lang/getEffectiveLocale.ts +52 -0
  15. package/src/lib/lang/getLocales.ts +10 -0
  16. package/src/lib/lang/toLanguage.ts +3 -0
  17. package/src/lib/logger/LogOptions.ts +8 -0
  18. package/src/lib/logger/ansiEscapeCodes.ts +6 -0
  19. package/src/lib/logger/levelColors.ts +4 -0
  20. package/src/lib/logger/log.ts +82 -0
  21. package/src/middleware/init.ts +22 -0
  22. package/src/middleware/lang.ts +83 -0
  23. package/src/middleware/requestEvents.ts +29 -0
  24. package/src/scripts/bin/build.ts +5 -0
  25. package/src/scripts/bin/startDev.ts +5 -0
  26. package/src/scripts/bin/startProd.ts +5 -0
  27. package/src/scripts/build.ts +45 -0
  28. package/src/scripts/cli.ts +46 -0
  29. package/src/scripts/const/commonBuildOptions.ts +13 -0
  30. package/src/scripts/const/entryExtensions.ts +1 -0
  31. package/src/scripts/start.ts +18 -0
  32. package/src/scripts/types/BuildParams.ts +9 -0
  33. package/src/scripts/utils/buildClient.ts +41 -0
  34. package/src/scripts/utils/buildServer.ts +35 -0
  35. package/src/scripts/utils/buildServerCSS.ts +38 -0
  36. package/src/scripts/utils/createPostbuildPlugins.ts +66 -0
  37. package/src/scripts/utils/getEntries.ts +22 -0
  38. package/src/scripts/utils/getEntryPoints.ts +25 -0
  39. package/src/scripts/utils/getFirstAvailable.ts +22 -0
  40. package/src/scripts/utils/populateEntries.ts +28 -0
  41. package/src/scripts/utils/toImportPath.ts +12 -0
  42. package/src/types/Controller.ts +4 -0
  43. package/src/types/ErrorController.ts +3 -0
  44. package/src/types/LogEventPayload.ts +12 -0
  45. package/src/types/LogLevel.ts +1 -0
  46. package/src/types/Middleware.ts +7 -0
  47. package/src/types/MiddlewareSet.ts +3 -0
  48. package/src/types/RenderStatus.ts +9 -0
  49. package/src/types/ReqCtx.ts +11 -0
  50. package/src/types/TransformContent.ts +11 -0
  51. package/src/types/express.d.ts +15 -0
  52. package/src/types/global.d.ts +17 -0
  53. package/src/utils/createApp.ts +44 -0
  54. package/src/utils/cspNonce.ts +6 -0
  55. package/src/utils/emitLog.ts +18 -0
  56. package/src/utils/getEntries.ts +22 -0
  57. package/src/utils/getStatusMessage.ts +5 -0
  58. package/src/utils/injectNonce.ts +7 -0
  59. package/src/utils/renderStatus.ts +20 -0
  60. package/src/utils/resolveFilePath.ts +78 -0
  61. package/src/utils/serializeState.ts +3 -0
  62. package/src/utils/servePipeableStream.ts +32 -0
  63. package/tsconfig.json +18 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,622 @@
1
+ import { access, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
2
+ import { basename, extname, join, posix, relative, sep } from "node:path";
3
+ import { formatDate, formatDuration } from "dateshape";
4
+ import { randomBytes } from "node:crypto";
5
+ import { STATUS_CODES } from "node:http";
6
+ import { spawn } from "node:child_process";
7
+ import esbuild from "esbuild";
8
+ import EventEmitter from "node:events";
9
+ import express from "express";
10
+
11
+ function emitLog(app, message, payload) {
12
+ let normalizedPayload = {
13
+ timestamp: Date.now(),
14
+ ...payload,
15
+ ...typeof message === "string" || message instanceof Error ? { message } : message
16
+ };
17
+ return app.events?.emit("log", normalizedPayload);
18
+ }
19
+
20
+ function toLanguage(locale) {
21
+ return locale.split(/[-_]/)[0];
22
+ }
23
+
24
+ async function resolveFilePath({ name, dir = ".", lang, supportedLocales = [], ext, index }) {
25
+ let cwd = process.cwd();
26
+ let localeSet = new Set(supportedLocales);
27
+ let langSet = new Set(supportedLocales.map(toLanguage));
28
+ let availableNames = [name, ...[...localeSet, ...langSet].map((item) => `${name}.${item}`)];
29
+ let preferredLangNames;
30
+ if (lang && (!supportedLocales.length || localeSet.has(lang) || langSet.has(lang))) preferredLangNames = [`${name}.${lang}`, `${name}.${toLanguage(lang)}`];
31
+ let names = new Set(preferredLangNames ? [...preferredLangNames, ...availableNames] : availableNames);
32
+ let exts = Array.isArray(ext) ? ext : [ext];
33
+ for (let item of names) for (let itemExt of exts) {
34
+ let path = join(cwd, dir, `${item}${itemExt ? `.${itemExt}` : ""}`);
35
+ try {
36
+ await access(path);
37
+ return path;
38
+ } catch {}
39
+ }
40
+ if (index) for (let item of names) for (let itemExt of exts) {
41
+ let path = join(cwd, dir, item, `index${itemExt ? `.${itemExt}` : ""}`);
42
+ try {
43
+ await access(path);
44
+ return path;
45
+ } catch {}
46
+ }
47
+ }
48
+
49
+ const defaultExt = ["html", "htm"];
50
+ const defaultName = (req) => req.path.split("/").at(-1);
51
+ /**
52
+ * Serves files from the specified directory path in a locale-aware
53
+ * fashion after applying optional transforms.
54
+ *
55
+ * A file ending with `.<lang>.<ext>` is picked first if the `<lang>`
56
+ * part matches `req.ctx.lang`. If the `supportedLocales` array is
57
+ * provided, the `*.<lang>.<ext>` file is picked only if the given
58
+ * array contains `req.ctx.lang`. Otherwise, a file without the locale
59
+ * in its path (`*.<ext>`) is picked.
60
+ */
61
+ const dir = ({ path, name = defaultName, ext = defaultExt, transform, supportedLocales, index = true }) => {
62
+ if (typeof path !== "string") throw new Error(`'path' is not a string`);
63
+ let transformSet = (Array.isArray(transform) ? transform : [transform]).filter((item) => typeof item === "function");
64
+ return async (req, res) => {
65
+ let fileName = typeof name === "function" ? name(req, res) : name;
66
+ emitLog(req.app, `Name: ${JSON.stringify(fileName)}`, {
67
+ req,
68
+ res
69
+ });
70
+ if (fileName === void 0) {
71
+ res.status(404).send(await req.app.renderStatus?.(req, res));
72
+ return;
73
+ }
74
+ let filePath = await resolveFilePath({
75
+ name: fileName,
76
+ dir: path,
77
+ ext,
78
+ supportedLocales,
79
+ lang: req.ctx?.lang,
80
+ index
81
+ });
82
+ emitLog(req.app, `Path: ${JSON.stringify(filePath)}`, {
83
+ req,
84
+ res
85
+ });
86
+ if (!filePath) {
87
+ res.status(404).send(await req.app.renderStatus?.(req, res));
88
+ return;
89
+ }
90
+ let content = (await readFile(filePath)).toString();
91
+ for (let transformItem of transformSet) content = await transformItem(req, res, {
92
+ content,
93
+ path: filePath,
94
+ name: basename(filePath, extname(filePath))
95
+ });
96
+ res.send(content);
97
+ };
98
+ };
99
+
100
+ const unhandledError = () => async (err, req, res) => {
101
+ emitLog(req.app, "Unhandled error", {
102
+ level: "error",
103
+ data: err,
104
+ req,
105
+ res
106
+ });
107
+ res.status(500).send(await req.app.renderStatus?.(req, res, "unhandled_error"));
108
+ };
109
+
110
+ const unhandledRoute = () => async (req, res) => {
111
+ emitLog(req.app, "Unhandled route", {
112
+ level: "debug",
113
+ req,
114
+ res
115
+ });
116
+ res.status(404).send(await req.app.renderStatus?.(req, res, "unhandled_route"));
117
+ };
118
+
119
+ function getEffectiveLocale(preferredLocales, supportedLocales) {
120
+ if (!supportedLocales || supportedLocales.length === 0) return void 0;
121
+ if (!preferredLocales || preferredLocales.length === 0) return supportedLocales[0];
122
+ let exactMatch = {};
123
+ for (let i = 0; i < preferredLocales.length && !exactMatch.locale; i++) {
124
+ let k = supportedLocales.indexOf(preferredLocales[i]);
125
+ if (k !== -1) {
126
+ exactMatch.index = i;
127
+ exactMatch.locale = supportedLocales[k];
128
+ }
129
+ }
130
+ let languageMatch = {};
131
+ let supportedLanguages = supportedLocales.map(toLanguage);
132
+ let preferredLanguages = preferredLocales.map(toLanguage);
133
+ for (let i = 0; i < preferredLanguages.length && !languageMatch.locale; i++) {
134
+ let k = supportedLanguages.indexOf(preferredLanguages[i]);
135
+ if (k !== -1) {
136
+ languageMatch.index = i;
137
+ languageMatch.locale = supportedLocales[k];
138
+ }
139
+ }
140
+ if (exactMatch.locale && (!languageMatch.locale || exactMatch.index < languageMatch.index)) return exactMatch.locale;
141
+ return languageMatch.locale ?? supportedLocales[0];
142
+ }
143
+
144
+ /**
145
+ * Parses a language range string (typically a value of the 'Accept-Language'
146
+ * HTTP request header) and returns a corresponding array of locales
147
+ * @example 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5'
148
+ */
149
+ function getLocales(languageRange) {
150
+ return (languageRange ?? "").split(/[,;]\s*/).filter((s) => !s.startsWith("q=") && s !== "*");
151
+ }
152
+
153
+ const ansiEscapeCodes = {
154
+ reset: "\x1B[0m",
155
+ dim: "\x1B[2m",
156
+ red: "\x1B[31m",
157
+ yellow: "\x1B[33m"
158
+ };
159
+
160
+ const levelColors = {
161
+ error: "red",
162
+ warn: "yellow"
163
+ };
164
+
165
+ function isEmpty(x) {
166
+ if (x === null || x === void 0 || x === "") return true;
167
+ if (Array.isArray(x) && x.length === 0) return true;
168
+ if (typeof x === "object" && Object.keys(x).length === 0) return true;
169
+ return false;
170
+ }
171
+ function log(message = "", { timestamp, level, data, req } = {}) {
172
+ let currentTime = timestamp ?? Date.now();
173
+ let error = null;
174
+ if (message instanceof Error) {
175
+ error = message;
176
+ message = error.message;
177
+ data = {
178
+ error,
179
+ data
180
+ };
181
+ if (!level) level = "error";
182
+ }
183
+ if (data instanceof Error) {
184
+ error = data;
185
+ if (data.message) message = `${message ? `${message} - ` : ""}${data.message}`;
186
+ if (!level) level = "error";
187
+ }
188
+ if (!level) level = "info";
189
+ let levelCode = ansiEscapeCodes[levelColors[level]] ?? "";
190
+ let requestTarget = req ? `${req.method} ${req.originalUrl}` : "";
191
+ let { startTime, id: sessionId } = req?.ctx ?? {};
192
+ console[level]();
193
+ console[level](levelCode + ansiEscapeCodes.dim + formatDate(currentTime, "{isoDate} {isoTimeMs} {tz}") + (sessionId ? ` <${sessionId}>` : "") + (startTime === void 0 ? "" : ` +${formatDuration(currentTime - startTime)}`) + ansiEscapeCodes.reset);
194
+ console[level](levelCode + requestTarget + (requestTarget && message && !message.startsWith("\n") ? " - " : "") + message + ansiEscapeCodes.reset);
195
+ if (!isEmpty(data)) console[level](levelCode + ansiEscapeCodes.dim + (typeof data === "string" ? data : JSON.stringify(data, null, 2)) + ansiEscapeCodes.reset);
196
+ if (error?.stack) console[level](levelCode + ansiEscapeCodes.dim + error.stack + ansiEscapeCodes.reset);
197
+ }
198
+
199
+ /**
200
+ * Initializes the request context on `req.ctx`.
201
+ */
202
+ const init = () => (req, res, next) => {
203
+ req.ctx = {
204
+ ...req.ctx,
205
+ id: randomBytes(16).toString("hex"),
206
+ nonce: randomBytes(8).toString("hex"),
207
+ startTime: Date.now()
208
+ };
209
+ emitLog(req.app, "Inited", {
210
+ req,
211
+ res
212
+ });
213
+ next();
214
+ };
215
+
216
+ const defaultLangCookieOptions = { maxAge: 90 * 864e5 };
217
+ const lang = ({ supportedLocales = [], shouldSetCookie = true, shouldRedirect = true, langCookieOptions = defaultLangCookieOptions } = {}) => {
218
+ let langSet = new Set(supportedLocales.map(toLanguage));
219
+ let localeSet = new Set(supportedLocales);
220
+ return (req, res, next) => {
221
+ let langParam = req.query.lang;
222
+ let lang = (Array.isArray(langParam) ? langParam[0] : langParam) ?? "";
223
+ if (localeSet.has(lang) || langSet.has(lang)) {
224
+ if (shouldSetCookie) {
225
+ emitLog(req.app, `Set lang cookie: ${JSON.stringify(lang)}`, {
226
+ req,
227
+ res
228
+ });
229
+ res.cookie("lang", lang, langCookieOptions);
230
+ }
231
+ if (shouldRedirect) {
232
+ let { originalUrl } = req;
233
+ let nextUrl = originalUrl.replace(/[?&]lang=[^&]+/g, "");
234
+ if (nextUrl !== originalUrl) {
235
+ emitLog(req.app, "Strip lang param and redirect", {
236
+ req,
237
+ res
238
+ });
239
+ res.redirect(nextUrl);
240
+ return;
241
+ }
242
+ }
243
+ }
244
+ let langCookie = shouldSetCookie ? req.cookies.lang : void 0;
245
+ let userAgentLocales = getLocales(req.get("accept-language"));
246
+ let effectiveLang = getEffectiveLocale(langCookie ? [langCookie, ...userAgentLocales] : userAgentLocales, supportedLocales);
247
+ if (req.ctx && effectiveLang) req.ctx.lang = effectiveLang;
248
+ emitLog(req.app, `Detected lang: ${JSON.stringify(effectiveLang)}`, {
249
+ req,
250
+ res,
251
+ data: {
252
+ userAgentLocales,
253
+ langCookie,
254
+ lang: effectiveLang
255
+ }
256
+ });
257
+ next();
258
+ };
259
+ };
260
+
261
+ function getStatusMessage(prefix, statusCode) {
262
+ return `${prefix} - [${statusCode}] ${STATUS_CODES[statusCode]}`;
263
+ }
264
+
265
+ /**
266
+ * Adds event handlers, like logging, to essential request phases.
267
+ */
268
+ const requestEvents = () => (req, res, next) => {
269
+ let finished = false;
270
+ res.on("finish", () => {
271
+ finished = true;
272
+ emitLog(req.app, getStatusMessage("Finished", res.statusCode), {
273
+ req,
274
+ res
275
+ });
276
+ });
277
+ res.on("close", () => {
278
+ if (!finished) emitLog(req.app, getStatusMessage("Closed", res.statusCode), {
279
+ req,
280
+ res
281
+ });
282
+ });
283
+ next();
284
+ };
285
+
286
+ const commonBuildOptions = {
287
+ format: "cjs",
288
+ jsx: "automatic",
289
+ jsxDev: process.env.NODE_ENV === "development",
290
+ loader: {
291
+ ".png": "dataurl",
292
+ ".svg": "dataurl",
293
+ ".html": "text",
294
+ ".txt": "text"
295
+ }
296
+ };
297
+
298
+ async function getEntries() {
299
+ let cwd = process.cwd();
300
+ try {
301
+ let list = await readdir(join(cwd, "src/entries"));
302
+ return (await Promise.all(list.map(async (name) => {
303
+ return (await lstat(join(cwd, "src/entries", name))).isDirectory() ? name : void 0;
304
+ }))).filter((dir) => dir !== void 0);
305
+ } catch {
306
+ return [];
307
+ }
308
+ }
309
+
310
+ const entryExtensions = [
311
+ "js",
312
+ "jsx",
313
+ "ts",
314
+ "tsx"
315
+ ];
316
+
317
+ async function getFirstAvailable(dirPath, path) {
318
+ let paths = Array.isArray(path) ? path : [path];
319
+ for (let filePath of paths) for (let ext of entryExtensions) {
320
+ let path = join(process.cwd(), dirPath, `${filePath}.${ext}`);
321
+ try {
322
+ await access(path);
323
+ return path;
324
+ } catch {}
325
+ }
326
+ }
327
+
328
+ async function getEntryPoints(path) {
329
+ let entries = await getEntries();
330
+ return (await Promise.all(entries.map(async (name) => {
331
+ let resolvedPath = await getFirstAvailable(`src/entries/${name}`, path);
332
+ return resolvedPath === void 0 ? void 0 : {
333
+ name,
334
+ path: resolvedPath
335
+ };
336
+ }))).filter((item) => item !== void 0);
337
+ }
338
+
339
+ /**
340
+ * Builds the client-side code from the 'src/entries/<entry_name>/ui'
341
+ * directories. The directories should preferrably be called 'ui' rather
342
+ * than client since their contents can also be used with the server-side
343
+ * rendering.
344
+ */
345
+ async function buildClient({ publicAssetsDir, watch, watchClient }, plugins) {
346
+ let clientEntries = await getEntryPoints(["ui/index"]);
347
+ let buildOptions = {
348
+ ...commonBuildOptions,
349
+ entryPoints: clientEntries.map(({ path }) => path),
350
+ bundle: true,
351
+ splitting: true,
352
+ format: "esm",
353
+ outdir: `${publicAssetsDir}/-`,
354
+ outbase: "src/entries",
355
+ minify: process.env.NODE_ENV !== "development",
356
+ plugins
357
+ };
358
+ if (watch || watchClient) {
359
+ let ctx = await esbuild.context(buildOptions);
360
+ await ctx.watch();
361
+ return async () => {
362
+ await ctx.dispose();
363
+ };
364
+ }
365
+ await esbuild.build(buildOptions);
366
+ }
367
+
368
+ function toImportPath(relativePath, referencePath = ".") {
369
+ let cwd = process.cwd();
370
+ let importPath = posix.join(...relative(join(cwd, referencePath), relativePath).split(sep));
371
+ if (importPath && !/^\.+\//.test(importPath)) importPath = `./${importPath}`;
372
+ return importPath;
373
+ }
374
+
375
+ async function populateEntries() {
376
+ let serverEntries = await getEntryPoints(["server", "server/index"]);
377
+ let content = "";
378
+ if (serverEntries.length === 0) content = "export const entries = [];";
379
+ else {
380
+ content = "export const entries = (\n await Promise.all([";
381
+ for (let i = 0; i < serverEntries.length; i++) content += `\n // ${serverEntries[i].name}
382
+ import("${toImportPath(serverEntries[i].path, "src/server")}"),`;
383
+ content += "\n ])\n).map(({ server }) => server);";
384
+ }
385
+ await writeFile("src/server/entries.ts", `// Populated automatically during the build phase by picking
386
+ // all server exports from 'src/entries/<entry_name>/server(/index)?.(js|ts)'
387
+ ${content}
388
+ `);
389
+ }
390
+
391
+ async function buildServer({ targetDir, watch, watchServer }, plugins) {
392
+ await populateEntries();
393
+ let buildOptions = {
394
+ ...commonBuildOptions,
395
+ entryPoints: ["src/server/index.ts"],
396
+ bundle: true,
397
+ splitting: true,
398
+ outdir: `${targetDir}/server`,
399
+ platform: "node",
400
+ format: "esm",
401
+ packages: "external",
402
+ plugins
403
+ };
404
+ if (watch || watchServer) {
405
+ let ctx = await esbuild.context(buildOptions);
406
+ await ctx.watch();
407
+ return async () => {
408
+ await ctx.dispose();
409
+ };
410
+ }
411
+ await esbuild.build(buildOptions);
412
+ }
413
+
414
+ async function buildServerCSS({ targetDir, watch, watchServer }, plugins) {
415
+ let serverEntries = await getEntryPoints(["server", "server/index"]);
416
+ let buildOptions = {
417
+ ...commonBuildOptions,
418
+ entryPoints: serverEntries.map(({ name, path }) => ({
419
+ in: path,
420
+ out: name
421
+ })),
422
+ bundle: true,
423
+ splitting: false,
424
+ outdir: `${targetDir}/server-css`,
425
+ platform: "node",
426
+ format: "esm",
427
+ packages: "external",
428
+ plugins
429
+ };
430
+ if (watch || watchServer) {
431
+ let ctx = await esbuild.context(buildOptions);
432
+ await ctx.watch();
433
+ return async () => {
434
+ await ctx.dispose();
435
+ };
436
+ }
437
+ await esbuild.build(buildOptions);
438
+ }
439
+
440
+ function createPostbuildPlugins({ targetDir, publicAssetsDir }, onServerRebuild) {
441
+ return {
442
+ serverPlugins: [{
443
+ name: "skip-css",
444
+ setup(build) {
445
+ /** @see https://github.com/evanw/esbuild/issues/599#issuecomment-745118158 */
446
+ build.onLoad({ filter: /\.css$/ }, () => ({ contents: "" }));
447
+ }
448
+ }, {
449
+ name: "postbuild-server",
450
+ setup(build) {
451
+ build.onEnd(() => {
452
+ onServerRebuild();
453
+ });
454
+ }
455
+ }],
456
+ serverCSSPlugins: [{
457
+ name: "postbuild-server-css",
458
+ setup(build) {
459
+ build.onEnd(async () => {
460
+ let dir = `${targetDir}/server-css`;
461
+ try {
462
+ let files = (await readdir(dir)).filter((name) => name.endsWith(".css"));
463
+ if (files.length === 0) return;
464
+ await mkdir(`${publicAssetsDir}/-`, { recursive: true });
465
+ await Promise.all(files.map(async (name) => {
466
+ let dir = `${publicAssetsDir}/-/${name.slice(0, -4)}`;
467
+ await mkdir(dir, { recursive: true });
468
+ await rename(`${targetDir}/server-css/${name}`, `${dir}/index.css`);
469
+ }));
470
+ await rm(dir, {
471
+ recursive: true,
472
+ force: true
473
+ });
474
+ } catch {}
475
+ });
476
+ }
477
+ }]
478
+ };
479
+ }
480
+
481
+ async function build(params) {
482
+ let startTime = Date.now();
483
+ let log = params.silent ? () => {} : console.log;
484
+ log("Build started");
485
+ let serverProcess = null;
486
+ let inited = false;
487
+ function handleServerRebuild() {
488
+ if (serverProcess) {
489
+ serverProcess.kill();
490
+ serverProcess = null;
491
+ }
492
+ if (!inited) {
493
+ log(`Build completed +${formatDuration(Date.now() - startTime)}`);
494
+ inited = true;
495
+ }
496
+ if (params.start) serverProcess = spawn("node", [`${params.targetDir}/server/index.js`], { stdio: "inherit" });
497
+ }
498
+ let { serverPlugins, serverCSSPlugins } = createPostbuildPlugins(params, handleServerRebuild);
499
+ await Promise.all([
500
+ buildServer(params, serverPlugins),
501
+ buildServerCSS(params, serverCSSPlugins),
502
+ buildClient(params)
503
+ ]);
504
+ }
505
+
506
+ const defaultTargetDir = "dist";
507
+ async function clean({ targetDir, publicAssetsDir }) {
508
+ let dirs = [
509
+ `${targetDir}/server`,
510
+ `${targetDir}/server-css`,
511
+ `${publicAssetsDir}/-`
512
+ ];
513
+ return Promise.all(dirs.map((dir) => rm(dir, {
514
+ recursive: true,
515
+ force: true
516
+ })));
517
+ }
518
+ async function cli(args = []) {
519
+ let publicAssetsDir = args[0];
520
+ let targetDir = args[1];
521
+ if (!publicAssetsDir || publicAssetsDir.startsWith("--")) throw new Error("Public assets directory is undefined");
522
+ if (!targetDir || targetDir.startsWith("--")) targetDir = defaultTargetDir;
523
+ let params = {
524
+ targetDir,
525
+ publicAssetsDir,
526
+ silent: args.includes("--silent"),
527
+ watch: args.includes("--watch"),
528
+ watchServer: args.includes("--watch-server"),
529
+ watchClient: args.includes("--watch-client"),
530
+ start: args.includes("--start")
531
+ };
532
+ if (args.includes("--clean-only")) {
533
+ await clean(params);
534
+ return;
535
+ }
536
+ if (args.includes("--clean")) await clean(params);
537
+ await build(params);
538
+ }
539
+
540
+ async function start(nodeEnv = "development", host) {
541
+ if (nodeEnv) process.env.NODE_ENV = nodeEnv;
542
+ if (host) {
543
+ let [hostname, port] = host.split(":");
544
+ if (hostname) process.env.APP_HOST = hostname;
545
+ if (port) process.env.APP_PORT = port;
546
+ }
547
+ await cli(nodeEnv === "development" ? [
548
+ "src/public",
549
+ "--clean",
550
+ "--start",
551
+ "--watch"
552
+ ] : [
553
+ "src/public",
554
+ "--clean",
555
+ "--start",
556
+ "--silent"
557
+ ]);
558
+ }
559
+
560
+ const renderStatus = async (req, res) => {
561
+ let { id, nonce } = req.ctx;
562
+ let statusText = `${res.statusCode} ${STATUS_CODES[res.statusCode]}`;
563
+ let date = `${(/* @__PURE__ */ new Date()).toISOString().replace(/T/, " ").replace(/Z$/, "")} UTC`;
564
+ return `<!DOCTYPE html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width"/><title>${statusText}</title><style${nonce ? ` nonce="${nonce}"` : ""}>body{text-align:center;}</style></head><body><h1>${statusText}</h1><hr/><p>` + (id ? `<code>ID: ${id}</code><br/>` : "") + `<code>${date}</code></p></body></html>`;
565
+ };
566
+
567
+ function createApp(callback) {
568
+ let app = express();
569
+ if (!app.events) app.events = new EventEmitter();
570
+ let host = process.env.APP_HOST || "localhost";
571
+ let port = Number(process.env.APP_PORT) || 80;
572
+ let listen = () => {
573
+ app.listen(port, host, () => {
574
+ emitLog(app, `Server running at ${`http://${host}:${port}/`} (${`NODE_ENV=${process.env.NODE_ENV}`})`);
575
+ });
576
+ };
577
+ if (process.env.NODE_ENV === "development") app.events?.on("log", ({ message, ...payload }) => {
578
+ log(message, payload);
579
+ });
580
+ if (!app.renderStatus) app.renderStatus = renderStatus;
581
+ app.disable("x-powered-by");
582
+ app.use(init());
583
+ app.use(requestEvents());
584
+ let callbackResult = typeof callback === "function" ? callback() : null;
585
+ if (callbackResult instanceof Promise) callbackResult.then(listen);
586
+ else listen();
587
+ return app;
588
+ }
589
+
590
+ const cspNonce = (req) => {
591
+ return `'nonce-${req.ctx?.nonce}'`;
592
+ };
593
+
594
+ const injectNonce = (req, _res, { content }) => {
595
+ let { nonce } = req.ctx;
596
+ return nonce ? content.replace(/\{\{nonce\}\}/g, nonce) : content;
597
+ };
598
+
599
+ function serializeState(state) {
600
+ return JSON.stringify(state).replace(/</g, "\\x3c");
601
+ }
602
+
603
+ function servePipeableStream(req, res) {
604
+ return async ({ pipe }, error) => {
605
+ let statusCode = error ? 500 : 200;
606
+ emitLog(req.app, getStatusMessage("Stream", statusCode), {
607
+ level: error ? "error" : void 0,
608
+ req,
609
+ res,
610
+ data: error
611
+ });
612
+ res.status(statusCode);
613
+ if (statusCode >= 400) {
614
+ res.send(await req.app.renderStatus?.(req, res));
615
+ return;
616
+ }
617
+ res.set("Content-Type", "text/html");
618
+ pipe(res);
619
+ };
620
+ }
621
+
622
+ export { build, cli, createApp, cspNonce, dir, emitLog, getEffectiveLocale, getLocales, getStatusMessage, init, injectNonce, lang, log, renderStatus, requestEvents, resolveFilePath, serializeState, servePipeableStream, start, toLanguage, unhandledError, unhandledRoute };
package/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ export * from "./src/controllers/dir.ts";
2
+ export * from "./src/controllers/unhandledError.ts";
3
+ export * from "./src/controllers/unhandledRoute.ts";
4
+ export * from "./src/lib/lang/getEffectiveLocale.ts";
5
+ export * from "./src/lib/lang/getLocales.ts";
6
+ export * from "./src/lib/lang/toLanguage.ts";
7
+ export * from "./src/lib/logger/LogOptions.ts";
8
+ export * from "./src/lib/logger/log.ts";
9
+ export * from "./src/middleware/init.ts";
10
+ export * from "./src/middleware/lang.ts";
11
+ export * from "./src/middleware/requestEvents.ts";
12
+ export * from "./src/scripts/build.ts";
13
+ export * from "./src/scripts/cli.ts";
14
+ export * from "./src/scripts/start.ts";
15
+ export * from "./src/types/Controller.ts";
16
+ export * from "./src/types/ErrorController.ts";
17
+ export * from "./src/types/LogEventPayload.ts";
18
+ export * from "./src/types/LogLevel.ts";
19
+ export * from "./src/types/Middleware.ts";
20
+ export * from "./src/types/MiddlewareSet.ts";
21
+ export * from "./src/types/RenderStatus.ts";
22
+ export * from "./src/types/ReqCtx.ts";
23
+ export * from "./src/types/TransformContent.ts";
24
+ export * from "./src/utils/createApp.ts";
25
+ export * from "./src/utils/cspNonce.ts";
26
+ export * from "./src/utils/emitLog.ts";
27
+ export * from "./src/utils/getStatusMessage.ts";
28
+ export * from "./src/utils/injectNonce.ts";
29
+ export * from "./src/utils/renderStatus.ts";
30
+ export * from "./src/utils/resolveFilePath.ts";
31
+ export * from "./src/utils/serializeState.ts";
32
+ export * from "./src/utils/servePipeableStream.ts";
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "appstage",
3
+ "version": "0.2.1",
4
+ "description": "",
5
+ "main": "./dist/index.cjs",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "type": "module",
9
+ "bin": {
10
+ "appstage-build": "./dist/bin/build.js",
11
+ "appstage-dev": "./dist/bin/startDev.js",
12
+ "appstage-prod": "./dist/bin/startProd.js"
13
+ },
14
+ "scripts": {
15
+ "compile-build": "esbuild src/scripts/bin/build.ts --bundle --outfile=dist/bin/build.js --platform=node --external:esbuild --format=esm",
16
+ "compile-start-dev": "esbuild src/scripts/bin/startDev.ts --bundle --outfile=dist/bin/startDev.js --platform=node --external:esbuild --format=esm",
17
+ "compile-start-prod": "esbuild src/scripts/bin/startProd.ts --bundle --outfile=dist/bin/startProd.js --platform=node --external:esbuild --format=esm",
18
+ "preversion": "npx npm-run-all shape -p compile-build compile-start-dev compile-start-prod",
19
+ "shape": "npx codeshape",
20
+ "typecheck": "npx codeshape --typecheck-only"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/axtk/appstage.git"
25
+ },
26
+ "author": "axtk",
27
+ "license": "MIT",
28
+ "peerDependencies": {
29
+ "express": ">=5"
30
+ },
31
+ "devDependencies": {
32
+ "@types/express": "^5.0.6",
33
+ "@types/node": "^25.4.0"
34
+ },
35
+ "dependencies": {
36
+ "dateshape": "^1.1.2",
37
+ "esbuild": "^0.27.3"
38
+ }
39
+ }