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.
- package/LICENSE +21 -0
- package/README.md +0 -0
- package/dist/bin/build.js +532 -0
- package/dist/bin/startDev.js +545 -0
- package/dist/bin/startProd.js +545 -0
- package/dist/index.cjs +671 -0
- package/dist/index.d.ts +1162 -0
- package/dist/index.mjs +622 -0
- package/index.ts +32 -0
- package/package.json +39 -0
- package/src/controllers/dir.ts +119 -0
- package/src/controllers/unhandledError.ts +15 -0
- package/src/controllers/unhandledRoute.ts +14 -0
- package/src/lib/lang/getEffectiveLocale.ts +52 -0
- package/src/lib/lang/getLocales.ts +10 -0
- package/src/lib/lang/toLanguage.ts +3 -0
- package/src/lib/logger/LogOptions.ts +8 -0
- package/src/lib/logger/ansiEscapeCodes.ts +6 -0
- package/src/lib/logger/levelColors.ts +4 -0
- package/src/lib/logger/log.ts +82 -0
- package/src/middleware/init.ts +22 -0
- package/src/middleware/lang.ts +83 -0
- package/src/middleware/requestEvents.ts +29 -0
- package/src/scripts/bin/build.ts +5 -0
- package/src/scripts/bin/startDev.ts +5 -0
- package/src/scripts/bin/startProd.ts +5 -0
- package/src/scripts/build.ts +45 -0
- package/src/scripts/cli.ts +46 -0
- package/src/scripts/const/commonBuildOptions.ts +13 -0
- package/src/scripts/const/entryExtensions.ts +1 -0
- package/src/scripts/start.ts +18 -0
- package/src/scripts/types/BuildParams.ts +9 -0
- package/src/scripts/utils/buildClient.ts +41 -0
- package/src/scripts/utils/buildServer.ts +35 -0
- package/src/scripts/utils/buildServerCSS.ts +38 -0
- package/src/scripts/utils/createPostbuildPlugins.ts +66 -0
- package/src/scripts/utils/getEntries.ts +22 -0
- package/src/scripts/utils/getEntryPoints.ts +25 -0
- package/src/scripts/utils/getFirstAvailable.ts +22 -0
- package/src/scripts/utils/populateEntries.ts +28 -0
- package/src/scripts/utils/toImportPath.ts +12 -0
- package/src/types/Controller.ts +4 -0
- package/src/types/ErrorController.ts +3 -0
- package/src/types/LogEventPayload.ts +12 -0
- package/src/types/LogLevel.ts +1 -0
- package/src/types/Middleware.ts +7 -0
- package/src/types/MiddlewareSet.ts +3 -0
- package/src/types/RenderStatus.ts +9 -0
- package/src/types/ReqCtx.ts +11 -0
- package/src/types/TransformContent.ts +11 -0
- package/src/types/express.d.ts +15 -0
- package/src/types/global.d.ts +17 -0
- package/src/utils/createApp.ts +44 -0
- package/src/utils/cspNonce.ts +6 -0
- package/src/utils/emitLog.ts +18 -0
- package/src/utils/getEntries.ts +22 -0
- package/src/utils/getStatusMessage.ts +5 -0
- package/src/utils/injectNonce.ts +7 -0
- package/src/utils/renderStatus.ts +20 -0
- package/src/utils/resolveFilePath.ts +78 -0
- package/src/utils/serializeState.ts +3 -0
- package/src/utils/servePipeableStream.ts +32 -0
- 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
|
+
}
|