mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-24 05:05:20 -04:00
feat(rewrite): Loading locales, ping route, and easter eggs
This commit is contained in:
parent
4ec2a5ab43
commit
5ead881f09
@ -1,9 +1,15 @@
|
||||
# -- Example environment file, provided for convenience.
|
||||
# -- It is recommended that you read through it.
|
||||
|
||||
# -- Specifies the environment. If this variable is not provided, it defaults to "development".
|
||||
# -- Allowed values:
|
||||
# "development", "dev": Development environment
|
||||
# "production", "prod": Production environment
|
||||
ENVIRONMENT=development
|
||||
|
||||
# -- Whether or not to allow API calls for unpublished (e.g. not-yet-ready) locales
|
||||
ALLOW_UNPUBLISHED_LOCALES=true
|
||||
|
||||
# -- This base URL only refers to the backend.
|
||||
# -- This also means we don't need HOME_URL, since the API doesn't have a homepage per se.
|
||||
HTTP_BASE_URL=http://localhost:5000
|
||||
|
@ -6,7 +6,7 @@
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"format": "prettier",
|
||||
"format": "prettier -w .",
|
||||
"lint": "eslint src/",
|
||||
"clean": "rimraf ./dist ./tsconfig.tsbuildinfo",
|
||||
"build:server": "tsc",
|
||||
@ -22,9 +22,11 @@
|
||||
"@pronounspage/common": "workspace:*",
|
||||
"dotenv": "^16.3.1",
|
||||
"fastify": "^4.21.0",
|
||||
"fluent-json-schema": "^4.1.1",
|
||||
"pino": "^8.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.5.6",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"pino-pretty": "^10.2.0",
|
||||
"rimraf": "^5.0.1",
|
||||
|
@ -1,5 +1,7 @@
|
||||
import "dotenv/config";
|
||||
import { identity } from "@pronounspage/common/util";
|
||||
import { identity, parseBool, parseIntOrThrow } from "@pronounspage/common/util";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
export enum Environment {
|
||||
DEVELOPMENT = "dev",
|
||||
@ -13,26 +15,32 @@ export interface Config {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
security: {
|
||||
secret: string;
|
||||
};
|
||||
localeDataPath: string;
|
||||
allowUnpublishedLocales: boolean;
|
||||
}
|
||||
|
||||
function envVarOrDefault<T>(
|
||||
key: string,
|
||||
parse: (value: string) => T,
|
||||
defaultValue: T
|
||||
defaultValue: T,
|
||||
): T;
|
||||
function envVarOrDefault<T>(
|
||||
key: string,
|
||||
parse: (value: string) => T,
|
||||
defaultValue?: undefined
|
||||
defaultValue?: undefined,
|
||||
): T | undefined;
|
||||
function envVarOrDefault<T>(
|
||||
key: string,
|
||||
parse: (value: string) => T,
|
||||
defaultValue: T | undefined = undefined
|
||||
defaultValue: T | undefined = undefined,
|
||||
): T | undefined {
|
||||
const value = process.env[key];
|
||||
return value == null ? defaultValue : parse(String(value));
|
||||
}
|
||||
|
||||
function envVarNotNull<T>(key: string, parse: (value: string) => T): T {
|
||||
const value = envVarOrDefault(key, parse);
|
||||
if (value === undefined) {
|
||||
@ -54,21 +62,47 @@ function parseEnvironment(value: string): Environment {
|
||||
}
|
||||
}
|
||||
|
||||
function parsePath(value: string): string {
|
||||
return path.resolve(process.cwd(), value);
|
||||
}
|
||||
|
||||
export function loadConfigFromEnv(): Config {
|
||||
return {
|
||||
environment: envVarOrDefault(
|
||||
"ENVIRONMENT",
|
||||
parseEnvironment,
|
||||
Environment.DEVELOPMENT
|
||||
Environment.DEVELOPMENT,
|
||||
),
|
||||
http: {
|
||||
baseUrl: envVarNotNull("HTTP_BASE_URL", identity),
|
||||
host: envVarOrDefault("HTTP_HOST", identity, "0.0.0.0"),
|
||||
port: envVarOrDefault("HTTP_PORT", parseInt, 4000),
|
||||
port: envVarOrDefault("HTTP_PORT", parseIntOrThrow, 4000),
|
||||
},
|
||||
security: {
|
||||
secret: envVarNotNull("SECRET", identity),
|
||||
},
|
||||
localeDataPath: envVarOrDefault("LOCALE_DATA_PATH", parsePath, "../locales"),
|
||||
allowUnpublishedLocales: envVarOrDefault("ALLOW_UNPUBLISHED_LOCALES", parseBool, false),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateConfig(config: Config): string | undefined {
|
||||
if (!fs.existsSync(config.localeDataPath)) {
|
||||
return `Locale data path is set to ${config.localeDataPath}, but it does not exist`;
|
||||
}
|
||||
// I mean these two next ones really aren't necessary, but I find it funny
|
||||
// ...who said we can't have a little fun here and there?
|
||||
const secretLower = config.security.secret.toLowerCase();
|
||||
if (secretLower === "changeme") {
|
||||
return `You didn't change the secret? The secret is quite literally "${secretLower}"`;
|
||||
}
|
||||
if (secretLower === "changemeonprod!" && config.environment === Environment.PRODUCTION) {
|
||||
return `Hey now, this is production! You have to change the secret on prod! Look, it says so: ${config.security.secret}`
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let loadedConfig: Config | undefined = undefined;
|
||||
|
||||
export function getConfig() {
|
||||
|
@ -1,3 +1,27 @@
|
||||
import { startServer } from "#self/server";
|
||||
import { getConfig, validateConfig } from "#self/config";
|
||||
import log from "#self/log";
|
||||
import { exit } from "node:process";
|
||||
import { getActiveLocaleDescriptions, loadLocaleDescriptions } from "./locales.js";
|
||||
|
||||
try {
|
||||
const validationError = validateConfig(getConfig());
|
||||
if (validationError != undefined) {
|
||||
log.error(`Configuration is invalid: ${validationError}`);
|
||||
exit(1);
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(`Could not validate configuration: ${e}`);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const localeDescriptions = await loadLocaleDescriptions();
|
||||
const active = getActiveLocaleDescriptions();
|
||||
log.info(`${localeDescriptions.length} locale(s) are registered, of which ${active.length} are active`)
|
||||
} catch (e) {
|
||||
log.error(`Could not load locales: ${e}`);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
await startServer();
|
||||
|
45
new/backend/src/locales.ts
Normal file
45
new/backend/src/locales.ts
Normal file
@ -0,0 +1,45 @@
|
||||
/*
|
||||
* This file is for interop with the old locales.js file to reduce duplication.
|
||||
*/
|
||||
|
||||
import { getConfig } from "#self/config";
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface LocaleDescription {
|
||||
code: string;
|
||||
name: string;
|
||||
url: string;
|
||||
published: boolean;
|
||||
symbol: string;
|
||||
// This could technically be an enum but for now it's just a string as there
|
||||
// doesn't seem to be any apparent benefit to including locales
|
||||
family: string;
|
||||
}
|
||||
|
||||
let loaded: Array<LocaleDescription>;
|
||||
let active: Array<LocaleDescription> | undefined;
|
||||
|
||||
export async function loadLocaleDescriptions(): Promise<Array<LocaleDescription>> {
|
||||
if (loaded == undefined) {
|
||||
loaded = (await import("file://" + path.resolve(getConfig().localeDataPath, "locales.js"))).default;
|
||||
active = undefined;
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
export function getCachedLocaleDescriptions(): Array<LocaleDescription> {
|
||||
return loaded;
|
||||
}
|
||||
export function getActiveLocaleDescriptions(): Array<LocaleDescription> {
|
||||
if (active == undefined) {
|
||||
const config = getConfig();
|
||||
const cached = getCachedLocaleDescriptions();
|
||||
|
||||
active = [];
|
||||
for (const locale of cached) {
|
||||
if (config.allowUnpublishedLocales || locale.published) {
|
||||
active.push(locale)
|
||||
}
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
@ -17,6 +17,13 @@ const loggerConfigurations = {
|
||||
},
|
||||
} satisfies Record<Environment, LoggerOptions>;
|
||||
|
||||
export const log = pino(loggerConfigurations[getConfig().environment]);
|
||||
|
||||
let environment;
|
||||
try {
|
||||
environment = getConfig().environment
|
||||
} catch (e) {
|
||||
environment = Environment.DEVELOPMENT;
|
||||
}
|
||||
export const log = pino(loggerConfigurations[environment]);
|
||||
|
||||
export default log;
|
||||
|
@ -1,20 +1,35 @@
|
||||
import fastify from "fastify";
|
||||
import log from "#self/log";
|
||||
import { getConfig } from "./config.js";
|
||||
import { getConfig } from "#self/config";
|
||||
import v1 from "#self/server/v1";
|
||||
|
||||
export async function startServer() {
|
||||
const config = getConfig();
|
||||
const instance = fastify({
|
||||
const app = fastify({
|
||||
logger: log,
|
||||
});
|
||||
|
||||
let start: Date;
|
||||
|
||||
app.get("/ping", (req, reply) => {
|
||||
reply.send({
|
||||
message: `Pong! The server is running!`,
|
||||
status: {
|
||||
uptime: Date.now() - start.getTime(),
|
||||
},
|
||||
});
|
||||
});
|
||||
app.register(v1, { prefix: "/v1" });
|
||||
|
||||
try {
|
||||
await instance.listen({
|
||||
await app.listen({
|
||||
host: config.http.host,
|
||||
port: config.http.port,
|
||||
});
|
||||
start = new Date(Date.now());
|
||||
log.info(`(Public base URL: ${config.http.baseUrl})`);
|
||||
} catch (e) {
|
||||
instance.log.error(e);
|
||||
log.error(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
24
new/backend/src/server/v1.ts
Normal file
24
new/backend/src/server/v1.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { FastifyInstance, FastifyPluginAsync } from "fastify";
|
||||
import { S } from "fluent-json-schema";
|
||||
|
||||
/*
|
||||
* Version 1 of the API. This would be the "legacy" version.
|
||||
* Since the output of every endpoint is locale-specific,
|
||||
* every route is prefixed with the respective locale ID.
|
||||
*/
|
||||
|
||||
export const routes = async function (app: FastifyInstance) {
|
||||
const locales = S.object()
|
||||
.prop("locale", S.string());
|
||||
|
||||
app.get(
|
||||
"/:locale/pronouns",
|
||||
{
|
||||
schema: {
|
||||
params: locales
|
||||
},
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
} satisfies FastifyPluginAsync;
|
||||
export default routes;
|
0
new/backend/src/server/v1/user.ts
Normal file
0
new/backend/src/server/v1/user.ts
Normal file
@ -10,7 +10,10 @@
|
||||
"paths": {
|
||||
"#self/*": ["./src/*.js"]
|
||||
},
|
||||
"incremental": true
|
||||
"incremental": true,
|
||||
"strictNullChecks": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
||||
|
@ -39,5 +39,8 @@
|
||||
"npm-run-all": "^4.1.5",
|
||||
"rimraf": "^5.0.1",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"suml": "^0.2.2"
|
||||
}
|
||||
}
|
||||
|
1
new/common/src/global.d.ts
vendored
Normal file
1
new/common/src/global.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module "suml";
|
12
new/common/src/suml.ts
Normal file
12
new/common/src/suml.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import * as SumlImpl from "suml";
|
||||
|
||||
declare module "suml";
|
||||
|
||||
const instance = new SumlImpl();
|
||||
|
||||
export function parse(value: string): unknown {
|
||||
return instance.parse(value);
|
||||
}
|
||||
export function dump(value: unknown): string {
|
||||
return instance.dump(value);
|
||||
}
|
@ -1,3 +1,34 @@
|
||||
export function identity<T>(value: T): T {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseIntOrThrow(value: string, radix?: number): number {
|
||||
if (radix != null && (radix < 2 || radix > 36)) {
|
||||
throw new Error(`invalid radix ${radix} - must be between 2 and 36 (inclusive)`);
|
||||
}
|
||||
const parsed = parseInt(value, radix);
|
||||
if (isNaN(parsed)) {
|
||||
throw new Error("parsed value is NaN");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseBool(value: unknown): boolean {
|
||||
switch (typeof value) {
|
||||
case "boolean":
|
||||
return value;
|
||||
case "string":
|
||||
value = value.toLowerCase();
|
||||
if (value === "true" || value === "1" || value === "yes") {
|
||||
return true;
|
||||
}
|
||||
if (value === "false" || value === "0" || value === "no") {
|
||||
return false;
|
||||
}
|
||||
throw new Error(`unknown boolean constant: ${value}`);
|
||||
case "number":
|
||||
return value != 0;
|
||||
default:
|
||||
throw new Error(`Cannot convert value ${value} to a boolean`);
|
||||
}
|
||||
}
|
24
new/pnpm-lock.yaml
generated
24
new/pnpm-lock.yaml
generated
@ -38,10 +38,16 @@ importers:
|
||||
fastify:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
fluent-json-schema:
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1
|
||||
pino:
|
||||
specifier: ^8.15.0
|
||||
version: 8.15.0
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^20.5.6
|
||||
version: 20.5.6
|
||||
npm-run-all:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5
|
||||
@ -56,6 +62,10 @@ importers:
|
||||
version: 5.2.2
|
||||
|
||||
common:
|
||||
dependencies:
|
||||
suml:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
devDependencies:
|
||||
npm-run-all:
|
||||
specifier: ^4.1.5
|
||||
@ -197,6 +207,10 @@ packages:
|
||||
resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==}
|
||||
dev: true
|
||||
|
||||
/@types/node@20.5.6:
|
||||
resolution: {integrity: sha512-Gi5wRGPbbyOTX+4Y2iULQ27oUPrefaB0PxGQJnfyWN3kvEDGM3mIB5M/gQLmitZf7A9FmLeaqxD3L1CXpm3VKQ==}
|
||||
dev: true
|
||||
|
||||
/@types/semver@7.5.0:
|
||||
resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==}
|
||||
dev: true
|
||||
@ -969,6 +983,12 @@ packages:
|
||||
resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==}
|
||||
dev: true
|
||||
|
||||
/fluent-json-schema@4.1.1:
|
||||
resolution: {integrity: sha512-D62MTftvRErkaQcoxiRpMl9qD77g1JWSv5/J/KYjWX3jTXqGG3mnrbAQi3R2KODNnjtsKSdG8zgcTCX7ZKBaqQ==}
|
||||
dependencies:
|
||||
'@fastify/deepmerge': 1.3.0
|
||||
dev: false
|
||||
|
||||
/for-each@0.3.3:
|
||||
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
|
||||
dependencies:
|
||||
@ -2052,6 +2072,10 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
dev: true
|
||||
|
||||
/suml@0.2.2:
|
||||
resolution: {integrity: sha512-wsRkNkpwSKPfGxykX3+63ID2vxuAFqgl8lZdUh4fHDp0KAETYhmPZw8mjZoNRvfgjQH2jwPBixOaOm4eF79NXw==}
|
||||
dev: false
|
||||
|
||||
/supports-color@5.5.0:
|
||||
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
||||
engines: {node: '>=4'}
|
||||
|
33
new/setup.mjs
Normal file
33
new/setup.mjs
Normal file
@ -0,0 +1,33 @@
|
||||
import fsp from "node:fs/promises";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
console.log("Setting up project")
|
||||
|
||||
const url = new URL(import.meta.url);
|
||||
const projectDir = path.dirname(path.resolve(url.pathname.split('/').filter((v) => v && v.length > 0).join(path.sep)));
|
||||
const legacyDir = path.resolve(projectDir, "..");
|
||||
|
||||
console.log(`
|
||||
Project directory: ${projectDir}
|
||||
Legacy directory: ${legacyDir}
|
||||
`)
|
||||
|
||||
async function symlink(to, from) {
|
||||
if (!fs.existsSync(from)) {
|
||||
console.log(` Creating symlink from ${from} to ${to}`)
|
||||
await fsp.symlink(to, from, "dir")
|
||||
} else {
|
||||
console.log(` File ${from} already exists - not symlinking to ${to}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Create temporary symlinks
|
||||
// This step may very well be removed in the future.
|
||||
{
|
||||
const to = path.resolve(legacyDir, "locale"), from = path.resolve(projectDir, "locales");
|
||||
await symlink(to, from);
|
||||
}
|
||||
|
||||
console.log(`
|
||||
Done!`)
|
Loading…
x
Reference in New Issue
Block a user