feat(rewrite): Loading locales, ping route, and easter eggs

This commit is contained in:
tecc 2023-08-31 21:36:22 +02:00
parent 4ec2a5ab43
commit 5ead881f09
No known key found for this signature in database
GPG Key ID: 622EEC5BAE5EBD3A
16 changed files with 277 additions and 13 deletions

View File

@ -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". # -- Specifies the environment. If this variable is not provided, it defaults to "development".
# -- Allowed values: # -- Allowed values:
# "development", "dev": Development environment # "development", "dev": Development environment
# "production", "prod": Production environment # "production", "prod": Production environment
ENVIRONMENT=development 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 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. # -- 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 HTTP_BASE_URL=http://localhost:5000

View File

@ -6,7 +6,7 @@
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"format": "prettier", "format": "prettier -w .",
"lint": "eslint src/", "lint": "eslint src/",
"clean": "rimraf ./dist ./tsconfig.tsbuildinfo", "clean": "rimraf ./dist ./tsconfig.tsbuildinfo",
"build:server": "tsc", "build:server": "tsc",
@ -22,9 +22,11 @@
"@pronounspage/common": "workspace:*", "@pronounspage/common": "workspace:*",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"fastify": "^4.21.0", "fastify": "^4.21.0",
"fluent-json-schema": "^4.1.1",
"pino": "^8.15.0" "pino": "^8.15.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.5.6",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"pino-pretty": "^10.2.0", "pino-pretty": "^10.2.0",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",

View File

@ -1,5 +1,7 @@
import "dotenv/config"; 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 { export enum Environment {
DEVELOPMENT = "dev", DEVELOPMENT = "dev",
@ -13,26 +15,32 @@ export interface Config {
host: string; host: string;
port: number; port: number;
}; };
security: {
secret: string;
};
localeDataPath: string;
allowUnpublishedLocales: boolean;
} }
function envVarOrDefault<T>( function envVarOrDefault<T>(
key: string, key: string,
parse: (value: string) => T, parse: (value: string) => T,
defaultValue: T defaultValue: T,
): T; ): T;
function envVarOrDefault<T>( function envVarOrDefault<T>(
key: string, key: string,
parse: (value: string) => T, parse: (value: string) => T,
defaultValue?: undefined defaultValue?: undefined,
): T | undefined; ): T | undefined;
function envVarOrDefault<T>( function envVarOrDefault<T>(
key: string, key: string,
parse: (value: string) => T, parse: (value: string) => T,
defaultValue: T | undefined = undefined defaultValue: T | undefined = undefined,
): T | undefined { ): T | undefined {
const value = process.env[key]; const value = process.env[key];
return value == null ? defaultValue : parse(String(value)); return value == null ? defaultValue : parse(String(value));
} }
function envVarNotNull<T>(key: string, parse: (value: string) => T): T { function envVarNotNull<T>(key: string, parse: (value: string) => T): T {
const value = envVarOrDefault(key, parse); const value = envVarOrDefault(key, parse);
if (value === undefined) { 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 { export function loadConfigFromEnv(): Config {
return { return {
environment: envVarOrDefault( environment: envVarOrDefault(
"ENVIRONMENT", "ENVIRONMENT",
parseEnvironment, parseEnvironment,
Environment.DEVELOPMENT Environment.DEVELOPMENT,
), ),
http: { http: {
baseUrl: envVarNotNull("HTTP_BASE_URL", identity), baseUrl: envVarNotNull("HTTP_BASE_URL", identity),
host: envVarOrDefault("HTTP_HOST", identity, "0.0.0.0"), 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; let loadedConfig: Config | undefined = undefined;
export function getConfig() { export function getConfig() {

View File

@ -1,3 +1,27 @@
import { startServer } from "#self/server"; 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(); await startServer();

View 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;
}

View File

@ -17,6 +17,13 @@ const loggerConfigurations = {
}, },
} satisfies Record<Environment, LoggerOptions>; } 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; export default log;

View File

@ -1,20 +1,35 @@
import fastify from "fastify"; import fastify from "fastify";
import log from "#self/log"; 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() { export async function startServer() {
const config = getConfig(); const config = getConfig();
const instance = fastify({ const app = fastify({
logger: log, 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 { try {
await instance.listen({ await app.listen({
host: config.http.host, host: config.http.host,
port: config.http.port, port: config.http.port,
}); });
start = new Date(Date.now());
log.info(`(Public base URL: ${config.http.baseUrl})`);
} catch (e) { } catch (e) {
instance.log.error(e); log.error(e);
return; return;
} }
} }

View 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;

View File

View File

@ -10,7 +10,10 @@
"paths": { "paths": {
"#self/*": ["./src/*.js"] "#self/*": ["./src/*.js"]
}, },
"incremental": true "incremental": true,
"strictNullChecks": true,
"strict": true,
"skipLibCheck": true
}, },
"include": ["./src/**/*"] "include": ["./src/**/*"]
} }

View File

@ -39,5 +39,8 @@
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"typescript": "^5.2.2" "typescript": "^5.2.2"
},
"dependencies": {
"suml": "^0.2.2"
} }
} }

1
new/common/src/global.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module "suml";

12
new/common/src/suml.ts Normal file
View 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);
}

View File

@ -1,3 +1,34 @@
export function identity<T>(value: T): T { export function identity<T>(value: T): T {
return value; 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
View File

@ -38,10 +38,16 @@ importers:
fastify: fastify:
specifier: ^4.21.0 specifier: ^4.21.0
version: 4.21.0 version: 4.21.0
fluent-json-schema:
specifier: ^4.1.1
version: 4.1.1
pino: pino:
specifier: ^8.15.0 specifier: ^8.15.0
version: 8.15.0 version: 8.15.0
devDependencies: devDependencies:
'@types/node':
specifier: ^20.5.6
version: 20.5.6
npm-run-all: npm-run-all:
specifier: ^4.1.5 specifier: ^4.1.5
version: 4.1.5 version: 4.1.5
@ -56,6 +62,10 @@ importers:
version: 5.2.2 version: 5.2.2
common: common:
dependencies:
suml:
specifier: ^0.2.2
version: 0.2.2
devDependencies: devDependencies:
npm-run-all: npm-run-all:
specifier: ^4.1.5 specifier: ^4.1.5
@ -197,6 +207,10 @@ packages:
resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==}
dev: true dev: true
/@types/node@20.5.6:
resolution: {integrity: sha512-Gi5wRGPbbyOTX+4Y2iULQ27oUPrefaB0PxGQJnfyWN3kvEDGM3mIB5M/gQLmitZf7A9FmLeaqxD3L1CXpm3VKQ==}
dev: true
/@types/semver@7.5.0: /@types/semver@7.5.0:
resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==}
dev: true dev: true
@ -969,6 +983,12 @@ packages:
resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==}
dev: true 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: /for-each@0.3.3:
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
dependencies: dependencies:
@ -2052,6 +2072,10 @@ packages:
engines: {node: '>=8'} engines: {node: '>=8'}
dev: true dev: true
/suml@0.2.2:
resolution: {integrity: sha512-wsRkNkpwSKPfGxykX3+63ID2vxuAFqgl8lZdUh4fHDp0KAETYhmPZw8mjZoNRvfgjQH2jwPBixOaOm4eF79NXw==}
dev: false
/supports-color@5.5.0: /supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'} engines: {node: '>=4'}

33
new/setup.mjs Normal file
View 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!`)