Template
1
0

feat: initial boilerplate

This commit is contained in:
2025-08-11 20:45:41 +02:00
parent d98524254f
commit 1215a98afc
148 changed files with 6935 additions and 2060 deletions

View File

@@ -1,46 +0,0 @@
name: Publish
on:
workflow_dispatch:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Deno
uses: maximousblk/setup-deno@v2
- name: Setup Node.JS
uses: actions/setup-node@v4
with:
node-version: 22
- run: deno install
- run: deno task lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Deno
uses: maximousblk/setup-deno@v2
- run: deno install
- run: deno task test
publish:
runs-on: ubuntu-latest
needs: [lint, test]
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Publish package
run: npx jsr publish

View File

@@ -1,38 +0,0 @@
name: Test
on:
pull_request:
branches:
- main
push:
branches:
- main
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Deno
uses: maximousblk/setup-deno@v2
- name: Setup Node.JS
uses: actions/setup-node@v4
with:
node-version: 20
- run: deno install
- run: deno task lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Deno
uses: maximousblk/setup-deno@v2
- run: deno install
- run: deno task test
- run: deno task test:publish

1
.gitignore vendored
View File

@@ -1 +1,2 @@
.volumes
node_modules node_modules

14
.prettierrc Normal file
View File

@@ -0,0 +1,14 @@
{
"trailingComma": "all",
"tabWidth": 2,
"printWidth": 120,
"singleQuote": false,
"overrides": [
{
"files": "*.ts",
"options": {
"parser": "typescript"
}
}
]
}

28
.vscode/settings.json vendored
View File

@@ -1,10 +1,32 @@
{ {
"deno.enable": true, "deno.enable": true,
"editor.formatOnSave": true, "deno.lint": false,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit" "source.fixAll.eslint": "explicit"
}, },
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.formatOnSave": true,
} "editor.defaultFormatter": "esbenp.prettier-vscode",
},
"[typescriptreact]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
},
"[markdown]": {
"editor.defaultFormatter": null,
"editor.wordWrap": "off"
},
"eslint.options": {
"ignorePatterns": ["**/*.md"]
},
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"**/Thumbs.db": true
},
"vue.format.style.initialIndent": true,
"vue.format.script.initialIndent": true
} }

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM denoland/deno:2.3.1
ENV TZ=UTC
ENV PORT=8370
EXPOSE 8370
WORKDIR /app
COPY api/ ./api/
COPY relay/ ./relay/
COPY .npmrc .
COPY deno-docker.json ./deno.json
RUN chown -R deno:deno /app/
USER deno
RUN deno install --allow-scripts
CMD ["sh", "-c", "deno run --allow-all ./api/.tasks/migrate.ts && deno run --allow-all ./api/server.ts"]

16
LICENSE
View File

@@ -1,16 +0,0 @@
MIT License
Copyright 2025 Christoffer Rødvik.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the
Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
Commercial use is permitted, provided the Software is not sold, relicensed, or distributed as a stand-alone solution, whether in original or minimally modified form.
Use as part of a larger work, integrated product, or service is allowed.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,94 +1 @@
<p align="center"> # Boilerplate
<img src="https://user-images.githubusercontent.com/1998130/229430454-ca0f2811-d874-4314-b13d-c558de8eec7e.svg" />
</p>
# Relay
Relay is a full stack protocol for communicating between client and server. It is also built around the major HTTP methods allowing for creating public API endpoints.
## Quick Start
Following quick start guide gives a three part setup approach for `Relay`, `RelayApi`, and `RelayClient`.
### Relay
First thing we need is a relay instance, this is where our base procedure configuration is defined. This space should be environment agnostic meaning we should be able to import our relay instance in both back end front end environments.
```ts
import { Relay, rpc, route } from "@valkyr/relay";
export const relay = new Relay({
user: {
create: rpc
.method("user:create")
.params(
z.object({
name: z.string(),
email: z.string().check(z.email()),
}),
)
.result(z.string()),
update: route
.put("/users/:userId")
.params({ userId: z.uuid() })
.body(
z.object({
name: z.string().optional(),
email: z.string().optional()
}),
),
}
});
```
As we can see in the above example we are defining a new `method` procedure with an expected `params` and `result` contracts defined using zod schemas.
### API
To be able to process relay requests on our server we create a `RelayApi` instance which consumes our relay routes. We do this by retrieving the procedure from the relay and attaching a handler to it. When we define new procedure methods we get a new instance which we apply to the api, this ensures that the changes to the procedure on the server only affects the relay on the server.
```ts
import { NotFoundError } from "@valkyr/relay";
import { relay } from "@project/relay";
export const api = relay.api([
relay
.method("user:create")
.handle(async ({ name, email }) => {
const user = await db.users.insert({ name, email });
if (user === undefined) {
return new NotFoundError();
}
return user.id;
}),
relay
.put("/users/:userId")
.handle(async ({ userId }, { name, email }) => {
await db.users.update({ name, email }).where({ id: userId });
}),
]);
```
With the above example we now have a `method` handler for the `user:create` method, and a `put` handler for the `/users/:userId` route path.
### Web
Now that we have both our relay and api ready to recieve requests we can trigger a user creation request in our web application by creating a new `client` instance.
```ts
import { HttpAdapter } from "@valkyr/relay/http";
import { relay } from "@project/relay";
const client = relay.client({
adapter: new HttpAdapter("http://localhost:8080")
});
const userId = await client.user.create({
name: "John Doe",
email: "john.doe@fixture.none"
});
await client.user.update({ userId }, { name: "Jane Doe", email: "jane.doe@fixture.none" });
```

View File

@@ -1,51 +0,0 @@
import type { RelayAdapter, RelayRESTInput } from "../libraries/adapter.ts";
import { RelayError, UnprocessableContentError } from "../libraries/errors.ts";
import { RelayProcedureInput, RelayProcedureResponse } from "../mod.ts";
export class HttpAdapter implements RelayAdapter {
#id: number = 0;
constructor(readonly url: string) {}
async send({ method, params }: RelayProcedureInput): Promise<RelayProcedureResponse> {
const id = this.#id++;
const res = await fetch(this.url, {
method: "POST",
headers: { "x-relay-type": "rpc", "content-type": "application/json" },
body: JSON.stringify({ relay: "1.0", method, params, id }),
});
const contentType = res.headers.get("content-type");
if (contentType !== "application/json") {
return {
relay: "1.0",
error: new UnprocessableContentError(`Invalid 'content-type' in header header, expected 'application/json', received '${contentType}'`),
id,
};
}
const json = await res.json();
if ("error" in json) {
return {
relay: "1.0",
error: RelayError.fromJSON(json.error),
id,
};
}
return json;
}
async fetch({ method, url, query, body }: RelayRESTInput): Promise<any> {
const res = await fetch(`${url}${query}`, {
method,
headers: { "x-relay-type": "rest", "content-type": "application/json" },
body,
});
const data = await res.text();
if (res.status >= 400) {
throw new Error(data);
}
if (res.headers.get("content-type")?.includes("json")) {
return JSON.parse(data);
}
return data;
}
}

9
api/config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { config as auth } from "~libraries/auth/config.ts";
import { getEnvironmentVariable, toNumber } from "~libraries/config/mod.ts";
export const config = {
name: "valkyr",
host: getEnvironmentVariable("API_HOST", "0.0.0.0"),
port: getEnvironmentVariable("API_PORT", toNumber, "8370"),
...auth,
};

6
api/deno.json Normal file
View File

@@ -0,0 +1,6 @@
{
"imports": {
"~config": "./config.ts",
"~libraries/": "./libraries/"
}
}

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCy5ZoXkKP9mZTk
sKbQdSwspHZqyMH33Gby23+9ycNHMIww7djcWFfPRW4s7tu3SNaac6qVg9OI43+Z
6BPXxuh4nhQ4LX5No9iVEmcWvZtKE4ghwzsoU0llT7+aKl9UYvgqU1YX4zyfiyo2
bW0nVPasEHTyjLCVPK5BKlq+UmuyJTVcduALDnVETpUefu5Vca6tIRXsOovvAf5b
zmcxPccaXIatR/AeipxT0YWoInn8dxD3kyFgTPXtinuBZxvp6MUeSs5IE8OJRJRP
PEo1MQ9HFw9aYRIn9uIkbARbNZMGz77zB1+0TrPGyKOB5lLReWGMUFAJhjLrnTsY
z19se4kNAgMBAAECgf9QkG6A6ViiHIMnUskIDeP5Xir19d9kbGwrcn0F2OXYaX+l
Oot9w3KM6loRJx380/zk/e0Uch1MeZ2fyqQRUmAGQIzkXUm6LUWIekYQN6vZ3JlP
YA2/M+otdd8Tpws9hFSDMUlx0SP3GAi0cE48xdBkVAT0NjZ3Jjor7Wv6GLe//Kzg
1OVrbPAA/+RrPB+BQn5nmZFT0aLuLpyxB4f4ArHG/8DEBY49Syy7/3Ke0kfHMnhl
5Eg5Yau89wSLqEoUSuQvNixu/5nTTQ6v1VYPVG8D1hn773SbNoY9o5vZOPRl1P0q
9YC/qpzPJkm/A5TZLsoalIxuGTdwts+DaEeoKmECgYEA5CddLQbMNu9kYElxpSA3
xXoTL71ZBCQsWExmJrcGe2lQhGO40lF8jE6QnEvMt0mp8Dg9n2ih4J87+2Ozb0fp
2G2ilNeMxM7keywA/+Cwg71QyImppU0lQ5PYLv+pllfxN8FPpLBluy7rDahzphkn
1rijqI5d4bHNG6IgD2ynteECgYEAyLs2eBWxX39Jff3OdpSVmHf7NtacbtsUf1qM
RJSvLsiSwKn39n1+Y6ebzftxm/XD/j8FbN8XvMZMI4OrlfzP+YJaTybIbHrLzCE2
B5E9j0GbJRhJ/D3l9FQBGdY4g5yC4mgbncXURQqqQTtKk2d+ixZSrw8iyDGN+aMJ
ybqZoK0CgYALb6GvARk5Y7R/Uw8cPMou3tiZWv9cQsfqQSIZrLDpfLTpfeokuKrq
iYGcI/yF725SOS91jxQWI0Upa6zx1gP1skEk/szyjIBNYD5IlSWj5NhoxOW5AG3u
vjlm2a/RdmUD62+njKP8xvRHQftSBw7FJ4okh8ZS6suiJ/U9cK/TYQKBgFg+jTyP
dNGhuKJN0NUqjvVfUa4S/ORzJXizStTfdIAhpvpR/nN7SfPvfDw6nQBOM+JyvCTX
kqznlBNM0EL4yElNN/xx9UxTU4Ki2wjKngB7fAP7wJLGd3BI+c7s8R1S0etMj091
59KOVLimoytYJTZqEuFoywatWlfzh9sKUH1lAoGBAID6mqGL3SZhh+i2/kAytfzw
UswTQqA0CCBTzN/Eo1QozmUVTLQPj8rBchNSoiSc92y+lPIL8ePdU7imRB77i+9D
9MSmc5u3ACACOSkwF0JCEGN+Rju4HR5wwm3h6Kvf/FQ3yvSEOKAWhqXIY95qtYTU
j3O+iJbY32pbQsawIAkw
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsuWaF5Cj/ZmU5LCm0HUs
LKR2asjB99xm8tt/vcnDRzCMMO3Y3FhXz0VuLO7bt0jWmnOqlYPTiON/megT18bo
eJ4UOC1+TaPYlRJnFr2bShOIIcM7KFNJZU+/mipfVGL4KlNWF+M8n4sqNm1tJ1T2
rBB08oywlTyuQSpavlJrsiU1XHbgCw51RE6VHn7uVXGurSEV7DqL7wH+W85nMT3H
GlyGrUfwHoqcU9GFqCJ5/HcQ95MhYEz17Yp7gWcb6ejFHkrOSBPDiUSUTzxKNTEP
RxcPWmESJ/biJGwEWzWTBs++8wdftE6zxsijgeZS0XlhjFBQCYYy6507GM9fbHuJ
DQIDAQAB
-----END PUBLIC KEY-----

View File

@@ -0,0 +1,87 @@
import { Auth, ResolvedSession } from "@valkyr/auth";
import z from "zod";
import { db } from "~libraries/read-store/database.ts";
import { config } from "./config.ts";
export const auth = new Auth(
{
settings: {
algorithm: "RS256",
privateKey: config.privateKey,
publicKey: config.publicKey,
issuer: "https://balto.health",
audience: "https://balto.health",
},
session: z.object({
accountId: z.string(),
}),
permissions: {
admin: ["create", "read", "update", "delete"],
organization: ["create", "read", "update", "delete"],
consultant: ["create", "read", "update", "delete"],
task: ["create", "update", "read", "delete"],
} as const,
guards: [],
},
{
roles: {
async add(role) {
await db.collection("roles").insertOne(role);
},
async getById(id) {
const role = await db.collection("roles").findOne({ id });
if (role === null) {
return undefined;
}
return role;
},
async getBySession({ accountId }) {
const account = await db.collection("accounts").findOne({ id: accountId });
if (account === null) {
return [];
}
return db
.collection("roles")
.find({ id: { $in: account.roles } })
.toArray();
},
async setPermissions() {
throw new Error("MongoRolesProvider > .setPermissions is managed by Role aggregate projections");
},
async delete(id) {
await db.collection("roles").deleteOne({ id });
},
async assignAccount(roleId: string, accountId: string): Promise<void> {
await db.collection("accounts").updateOne(
{ id: accountId },
{
$push: {
roles: roleId,
},
},
);
},
async removeAccount(roleId: string, accountId: string): Promise<void> {
await db.collection("roles").updateOne(
{ id: accountId },
{
$pull: {
roles: roleId,
},
},
);
},
},
},
);
export type Session = ResolvedSession<typeof auth>;
export type Permissions = (typeof auth)["$permissions"];

View File

@@ -0,0 +1,25 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import type { SerializeOptions } from "cookie";
import { getEnvironmentVariable, toBoolean } from "~libraries/config/mod.ts";
export const config = {
privateKey: getEnvironmentVariable(
"AUTH_PRIVATE_KEY",
await readFile(resolve(import.meta.dirname!, ".keys", "private"), "utf-8"),
),
publicKey: getEnvironmentVariable(
"AUTH_PUBLIC_KEY",
await readFile(resolve(import.meta.dirname!, ".keys", "public"), "utf-8"),
),
cookie: (maxAge: number) =>
({
httpOnly: true,
secure: getEnvironmentVariable("AUTH_COOKIE_SECURE", toBoolean, "false"), // Set to true for HTTPS in production
maxAge,
path: "/",
sameSite: "strict",
}) satisfies SerializeOptions,
};

View File

@@ -0,0 +1,6 @@
import { auth } from "./auth.ts";
export * from "./auth.ts";
export * from "./config.ts";
export type Auth = typeof auth;

View File

@@ -0,0 +1,21 @@
import { parseArgs } from "@std/cli";
import { Parser, toString } from "./parsers.ts";
export function getArgsVariable(key: string, fallback?: string): string;
export function getArgsVariable<T extends Parser>(key: string, parse: T, fallback?: string): ReturnType<T>;
export function getArgsVariable<T extends Parser>(key: string, parse?: T, fallback?: string): ReturnType<T> {
if (typeof parse === "string") {
fallback = parse;
parse = undefined;
}
const flags = parseArgs(Deno.args);
const value = flags[key];
if (value === undefined) {
if (fallback !== undefined) {
return parse ? parse(fallback) : fallback;
}
throw new Error(`Config Exception: Missing ${key} variable in arguments`);
}
return parse ? parse(value) : toString(value);
}

View File

@@ -0,0 +1,79 @@
import { load } from "@std/dotenv";
import type { z } from "zod";
import { Env, Parser, toServiceEnv, toString } from "./parsers.ts";
const env = await load();
/**
* Get an environment variable and parse it to the desired type.
*
* @param key - Environment key to resolve.
* @param parse - Parser function to convert the value to the desired type. Default: `string`.
*/
export function getEnvironmentVariable(key: string, fallback?: string): string;
export function getEnvironmentVariable<T extends Parser>(key: string, parse: T, fallback?: string): ReturnType<T>;
export function getEnvironmentVariable<T extends Parser>(key: string, parse?: T, fallback?: string): ReturnType<T> {
if (typeof parse === "string") {
fallback = parse;
parse = undefined;
}
const value = env[key] ?? Deno.env.get(key);
if (value === undefined) {
if (fallback !== undefined) {
return parse ? parse(fallback) : fallback;
}
throw new Error(`Config Exception: Missing ${key} variable in configuration`);
}
return parse ? parse(value) : toString(value);
}
/**
* Get an environment variable, select value based on ENV map and parse it to the desired type. Can be used with simple primitives or objects / arrays
*
* @export
* @param {{
* key: string;
* envFallback?: FallbackEnvMap;
* fallback: string;
* validation: z.ZodTypeAny,
* }} options
* @param {string} options.key - the name of the env variable
* @param {object} options.envFallback - map with env specific fallbacks that will be used if none value provided
* @param {string} options.envFallback.local - example "local" SERVICE_ENV target fallback value
* @param {string} options.fallback - string fallback that will be used if no env variable found
* @param {z.ZodTypeAny} options.validation - Zod validation object or validation primitive
* @returns {z.infer<typeof validation>} - Returns the inferred type of the validation provided
*/
export function validateEnvVariable({
key,
envFallback,
fallback,
validation,
}: {
key: string;
validation: z.ZodTypeAny;
envFallback?: FallbackEnvMap;
fallback?: string;
}): z.infer<typeof validation> {
const serviceEnv = getEnvironmentVariable("SERVICE_ENV", toServiceEnv, "local");
const providedValue = env[key] ?? Deno.env.get(key);
const fallbackValue = typeof envFallback === "object" ? (envFallback[serviceEnv] ?? fallback) : fallback;
const toBeUsed = providedValue ?? fallbackValue;
try {
if (typeof toBeUsed === "string" && (toBeUsed.trim().startsWith("{") || toBeUsed.trim().startsWith("["))) {
return validation.parse(JSON.parse(toBeUsed));
}
return validation.parse(toBeUsed);
} catch (e) {
throw new Deno.errors.InvalidData(`Config Exception: Missing valid ${key} variable in configuration`, { cause: e });
}
}
type FallbackEnvMap = Partial<Record<Env, string>> & {
testing?: string;
local?: string;
stg?: string;
demo?: string;
prod?: string;
};

View File

@@ -0,0 +1,98 @@
const SERVICE_ENV = ["testing", "local", "stg", "demo", "prod"] as const;
/**
* Convert an variable to a string.
*
* @param value - Value to convert.
*/
export function toString(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (typeof value === "number") {
return value.toString();
}
throw new Error(`Config Exception: Cannot convert ${value} to string`);
}
/**
* Convert an variable to a number.
*
* @param value - Value to convert.
*/
export function toNumber(value: unknown): number {
if (typeof value === "number") {
return value;
}
if (typeof value === "string") {
return parseInt(value);
}
throw new Error(`Config Exception: Cannot convert ${value} to number`);
}
/**
* Convert an variable to a boolean.
*
* @param value - Value to convert.
*/
export function toBoolean(value: unknown): boolean {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
return value === "true" || value === "1";
}
throw new Error(`Config Exception: Cannot convert ${value} to boolean`);
}
/**
* Convert a variable to an array of strings.
*
* Expects a comma seprated, eg. foo,bar,foobar
*
* @param value - Value to convert.
*/
export function toArray(value: unknown): string[] {
if (typeof value === "string") {
if (value === "") {
return [];
}
return value.split(",");
}
throw new Error(`Config Exception: Cannot convert ${value} to array`);
}
/**
* Ensure the given value is a valid SERVICE_ENV variable.
*
* @param value - Value to validate.
*/
export function toServiceEnv(value: unknown): Env {
assertServiceEnv(value);
return value;
}
/*
|--------------------------------------------------------------------------------
| Assertions
|--------------------------------------------------------------------------------
*/
function assertServiceEnv(value: unknown): asserts value is Env {
if (typeof value !== "string") {
throw new Error(`Config Exception: Env ${value} is not a string`);
}
if ((SERVICE_ENV as unknown as string[]).includes(value) === false) {
throw new Error(`Config Exception: Invalid env ${value} provided`);
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type Parser = (value: unknown) => any;
export type Env = (typeof SERVICE_ENV)[number];

View File

@@ -0,0 +1,3 @@
export * from "./libraries/args.ts";
export * from "./libraries/environment.ts";
export * from "./libraries/parsers.ts";

View File

@@ -0,0 +1 @@
export * from "./password.ts";

View File

@@ -0,0 +1,11 @@
import * as bcrypt from "@felix/bcrypt";
export const password = { hash, verify };
async function hash(password: string): Promise<string> {
return bcrypt.hash(password);
}
async function verify(password: string, hash: string): Promise<boolean> {
return bcrypt.verify(password, hash);
}

View File

@@ -0,0 +1,48 @@
import { Collection, type CollectionOptions, type Db, type Document, type MongoClient } from "mongodb";
import { container } from "./container.ts";
export function getDatabaseAccessor<TSchemas extends Record<string, Document>>(
database: string,
): DatabaseAccessor<TSchemas> {
let instance: Db | undefined;
return {
get db(): Db {
if (instance === undefined) {
instance = this.client.db(database);
}
return instance;
},
get client(): MongoClient {
return container.get("client");
},
collection<TSchema extends keyof TSchemas>(
name: TSchema,
options?: CollectionOptions,
): Collection<TSchemas[TSchema]> {
return this.db.collection<TSchemas[TSchema]>(name.toString(), options);
},
};
}
export type DatabaseAccessor<TSchemas extends Record<string, Document>> = {
/**
* Database for given accessor.
*/
db: Db;
/**
* Lazy loaded mongo client.
*/
client: MongoClient;
/**
* Returns a reference to a MongoDB Collection. If it does not exist it will be created implicitly.
*
* Collection namespace validation is performed server-side.
*
* @param name - Collection name we wish to access.
* @param options - Optional settings for the command.
*/
collection<TSchema extends keyof TSchemas>(name: TSchema, options?: CollectionOptions): Collection<TSchemas[TSchema]>;
};

View File

@@ -0,0 +1,10 @@
import { getEnvironmentVariable, toNumber } from "~libraries/config/mod.ts";
export const config = {
mongo: {
host: getEnvironmentVariable("DB_MONGO_HOST", "localhost"),
port: getEnvironmentVariable("DB_MONGO_PORT", toNumber, "27017"),
user: getEnvironmentVariable("DB_MONGO_USER", "root"),
pass: getEnvironmentVariable("DB_MONGO_PASSWORD", "password"),
},
};

View File

@@ -0,0 +1,24 @@
import { MongoClient } from "mongodb";
export function getMongoClient(config: MongoConnectionInfo) {
return new MongoClient(getConnectionUrl(config));
}
export function getConnectionUrl({ host, port, user, pass }: MongoConnectionInfo): MongoConnectionUrl {
return `mongodb://${user}:${pass}@${host}:${port}`;
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type MongoConnectionUrl = `mongodb://${string}:${string}@${string}:${number}`;
export type MongoConnectionInfo = {
host: string;
port: number;
user: string;
pass: string;
};

View File

@@ -0,0 +1,6 @@
import { Container } from "@valkyr/inverse";
import { MongoClient } from "mongodb";
export const container = new Container<{
client: MongoClient;
}>("database");

View File

@@ -0,0 +1,3 @@
import type { CreateIndexesOptions, IndexSpecification } from "mongodb";
export const idIndex: [IndexSpecification, CreateIndexesOptions] = [{ id: 1 }, { unique: true }];

View File

@@ -0,0 +1,30 @@
import type { CreateIndexesOptions, Db, IndexSpecification } from "mongodb";
import { getCollectionsSet } from "./utilities.ts";
/**
* Takes a mongo database and registers the event store collections and
* indexes defined internally.
*
* @param db - Mongo database to register event store collections against.
* @param registrars - List of registrars to register with the database.
* @param logger - Logger method to print internal logs.
*/
export async function register(db: Db, registrars: Registrar[], logger?: (...args: any[]) => any) {
const list = await getCollectionsSet(db);
for (const { name, indexes } of registrars) {
if (list.has(name) === false) {
await db.createCollection(name);
}
for (const [indexSpec, options] of indexes) {
await db.collection(name).createIndex(indexSpec, options);
logger?.("Mongo Event Store > Collection '%s' is indexed [%O] with options %O", name, indexSpec, options ?? {});
}
logger?.("Mongo Event Store > Collection '%s' is registered", name);
}
}
export type Registrar = {
name: string;
indexes: [IndexSpecification, CreateIndexesOptions?][];
};

View File

@@ -0,0 +1,5 @@
import { config } from "../config.ts";
import { getMongoClient } from "../connection.ts";
import { container } from "../container.ts";
container.set("client", getMongoClient(config.mongo));

View File

@@ -0,0 +1,37 @@
import type { Db } from "mongodb";
import z, { ZodType } from "zod";
/**
* Get a Set of collections that exists on a given mongo database instance.
*
* @param db - Mongo database to fetch collection list for.
*/
export async function getCollectionsSet(db: Db) {
return db
.listCollections()
.toArray()
.then((collections) => new Set(collections.map((c) => c.name)));
}
export function toParsedDocuments<TSchema extends ZodType>(
schema: TSchema,
): (documents: unknown[]) => Promise<z.infer<TSchema>[]> {
return async function (documents: unknown[]) {
const parsed = [];
for (const document of documents) {
parsed.push(await schema.parseAsync(document));
}
return parsed;
};
}
export function toParsedDocument<TSchema extends ZodType>(
schema: TSchema,
): (document?: unknown) => Promise<z.infer<TSchema> | undefined> {
return async function (document: unknown) {
if (document === undefined || document === null) {
return undefined;
}
return schema.parseAsync(document);
};
}

View File

@@ -0,0 +1,268 @@
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
import { Avatar, Contact, Email, Name, Phone, Strategy } from "relay/schemas";
import { db, toAccountDriver } from "~libraries/read-store/mod.ts";
import { eventStore } from "../event-store.ts";
import { AccountCreatedData } from "../events/account.ts";
import { Auditor } from "../events/auditor.ts";
import { EventStoreFactory } from "../events/mod.ts";
import { projector } from "../projector.ts";
export class Account extends AggregateRoot<EventStoreFactory> {
static override readonly name = "account";
id!: string;
organizationId?: string;
type!: "admin" | "consultant" | "organization";
avatar?: Avatar;
name?: Name;
contact: Contact = {
emails: [],
phones: [],
};
strategies: Strategy[] = [];
createdAt!: Date;
updatedAt!: Date;
// -------------------------------------------------------------------------
// Factories
// -------------------------------------------------------------------------
static #reducer = makeAggregateReducer(Account);
static create(data: AccountCreatedData, meta: Auditor): Account {
return new Account().push({
type: "account:created",
data,
meta,
});
}
static async getById(stream: string): Promise<Account | undefined> {
return this.$store.reduce({ name: "account", stream, reducer: this.#reducer });
}
static async getByEmail(email: string): Promise<Account | undefined> {
return this.$store.reduce({ name: "account", relation: Account.emailRelation(email), reducer: this.#reducer });
}
// -------------------------------------------------------------------------
// Relations
// -------------------------------------------------------------------------
static emailRelation(email: string): `account:email:${string}` {
return `account:email:${email}`;
}
static passwordRelation(alias: string): `account:password:${string}` {
return `account:password:${alias}`;
}
// -------------------------------------------------------------------------
// Reducer
// -------------------------------------------------------------------------
with(event: EventStoreFactory["$events"][number]["$record"]): void {
switch (event.type) {
case "account:created": {
this.id = event.stream;
this.organizationId = event.data.type === "organization" ? event.data.organizationId : undefined;
this.type = event.data.type;
this.createdAt = getDate(event.created);
break;
}
case "account:avatar:added": {
this.avatar = { url: event.data };
this.updatedAt = getDate(event.created);
break;
}
case "account:name:added": {
this.name = event.data;
this.updatedAt = getDate(event.created);
break;
}
case "account:email:added": {
this.contact.emails.push(event.data);
this.updatedAt = getDate(event.created);
break;
}
case "account:phone:added": {
this.contact.phones.push(event.data);
this.updatedAt = getDate(event.created);
break;
}
case "strategy:email:added": {
this.strategies.push({ type: "email", value: event.data });
this.updatedAt = getDate(event.created);
break;
}
case "strategy:password:added": {
this.strategies.push({ type: "password", ...event.data });
this.updatedAt = getDate(event.created);
break;
}
}
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
addAvatar(url: string, meta: Auditor): this {
return this.push({
stream: this.id,
type: "account:avatar:added",
data: url,
meta,
});
}
addName(name: Name, meta: Auditor): this {
return this.push({
stream: this.id,
type: "account:name:added",
data: name,
meta,
});
}
addEmail(email: Email, meta: Auditor): this {
return this.push({
stream: this.id,
type: "account:email:added",
data: email,
meta,
});
}
addPhone(phone: Phone, meta: Auditor): this {
return this.push({
stream: this.id,
type: "account:phone:added",
data: phone,
meta,
});
}
addRole(roleId: string, meta: Auditor): this {
return this.push({
stream: this.id,
type: "account:role:added",
data: roleId,
meta,
});
}
addEmailStrategy(email: string, meta: Auditor): this {
return this.push({
stream: this.id,
type: "strategy:email:added",
data: email,
meta,
});
}
addPasswordStrategy(alias: string, password: string, meta: Auditor): this {
return this.push({
stream: this.id,
type: "strategy:password:added",
data: { alias, password },
meta,
});
}
// -------------------------------------------------------------------------
// Utilities
// -------------------------------------------------------------------------
toSession(): Session {
if (this.type === "organization") {
if (this.organizationId === undefined) {
throw new Error("Account .toSession failed, no organization id present");
}
return {
type: this.type,
accountId: this.id,
organizationId: this.organizationId,
};
}
return {
type: this.type,
accountId: this.id,
};
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type Session =
| {
type: "organization";
accountId: string;
organizationId: string;
}
| {
type: "admin" | "consultant";
accountId: string;
};
/*
|--------------------------------------------------------------------------------
| Projectors
|--------------------------------------------------------------------------------
*/
projector.on("account:created", async ({ stream, data }) => {
const schema: any = {
id: stream,
type: data.type,
contact: {
emails: [],
phones: [],
},
strategies: [],
roles: [],
};
if (data.type === "organization") {
schema.organizationId = data.organizationId;
}
await db.collection("accounts").insertOne(toAccountDriver(schema));
});
projector.on("account:avatar:added", async ({ stream: id, data: url }) => {
await db.collection("accounts").updateOne({ id }, { $set: { avatar: { url } } });
});
projector.on("account:name:added", async ({ stream: id, data: name }) => {
await db.collection("accounts").updateOne({ id }, { $set: { name } });
});
projector.on("account:email:added", async ({ stream: id, data: email }) => {
await db.collection("accounts").updateOne({ id }, { $push: { "contact.emails": email } });
});
projector.on("account:phone:added", async ({ stream: id, data: phone }) => {
await db.collection("accounts").updateOne({ id }, { $push: { "contact.phones": phone } });
});
projector.on("account:role:added", async ({ stream: id, data: roleId }) => {
await db.collection("accounts").updateOne({ id }, { $push: { roles: roleId } });
});
projector.on("strategy:email:added", async ({ stream: id, data: email }) => {
await eventStore.relations.insert(Account.emailRelation(email), id);
await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "email", value: email } } });
});
projector.on("strategy:password:added", async ({ stream: id, data: strategy }) => {
await eventStore.relations.insert(Account.passwordRelation(strategy.alias), id);
await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } });
});

View File

@@ -0,0 +1,78 @@
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
import { CodeIdentity } from "../events/code.ts";
import { EventStoreFactory } from "../events/mod.ts";
export class Code extends AggregateRoot<EventStoreFactory> {
static override readonly name = "code";
id!: string;
identity!: CodeIdentity;
value!: string;
createdAt!: Date;
claimedAt?: Date;
// -------------------------------------------------------------------------
// Factories
// -------------------------------------------------------------------------
static #reducer = makeAggregateReducer(Code);
static create(identity: CodeIdentity): Code {
return new Code().push({
type: "code:created",
data: {
identity,
value: crypto
.getRandomValues(new Uint8Array(5))
.map((v) => v % 10)
.join(""),
},
});
}
static async getById(stream: string): Promise<Code | undefined> {
return this.$store.reduce({
name: "code",
stream,
reducer: this.#reducer,
});
}
get isClaimed(): boolean {
return this.claimedAt !== undefined;
}
// -------------------------------------------------------------------------
// Folder
// -------------------------------------------------------------------------
with(event: EventStoreFactory["$events"][number]["$record"]): void {
switch (event.type) {
case "code:created": {
this.id = event.stream;
this.value = event.data.value;
this.identity = event.data.identity;
this.createdAt = getDate(event.created);
break;
}
case "code:claimed": {
this.claimedAt = getDate(event.created);
break;
}
}
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
claim(): this {
return this.push({
type: "code:claimed",
stream: this.id,
});
}
}

View File

@@ -0,0 +1,8 @@
import { AggregateFactory } from "@valkyr/event-store";
import { Account } from "./account.ts";
import { Code } from "./code.ts";
import { Organization } from "./organization.ts";
import { Role } from "./role.ts";
export const aggregates = new AggregateFactory([Account, Code, Organization, Role]);

View File

@@ -0,0 +1,65 @@
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
import { db } from "~libraries/read-store/mod.ts";
import { Auditor } from "../events/auditor.ts";
import { EventStoreFactory } from "../events/mod.ts";
import { projector } from "../projector.ts";
export class Organization extends AggregateRoot<EventStoreFactory> {
static override readonly name = "organization";
id!: string;
name!: string;
createdAt!: Date;
updatedAt!: Date;
// -------------------------------------------------------------------------
// Factories
// -------------------------------------------------------------------------
static #reducer = makeAggregateReducer(Organization);
static create(name: string, meta: Auditor): Organization {
return new Organization().push({
type: "organization:created",
data: { name },
meta,
});
}
static async getById(stream: string): Promise<Organization | undefined> {
return this.$store.reduce({ name: "organization", stream, reducer: this.#reducer });
}
// -------------------------------------------------------------------------
// Reducer
// -------------------------------------------------------------------------
with(event: EventStoreFactory["$events"][number]["$record"]): void {
switch (event.type) {
case "organization:created": {
this.id = event.stream;
this.name = event.data.name;
this.createdAt = getDate(event.created);
break;
}
}
}
}
/*
|--------------------------------------------------------------------------------
| Projectors
|--------------------------------------------------------------------------------
*/
projector.on("organization:created", async ({ stream: id, data: { name }, created }) => {
await db.collection("organizations").insertOne({
id,
name,
createdAt: getDate(created),
});
});

View File

@@ -0,0 +1,118 @@
import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store";
import { db } from "~libraries/read-store/database.ts";
import type { Auditor } from "../events/auditor.ts";
import { EventStoreFactory } from "../events/mod.ts";
import type { RoleCreatedData, RolePermissionOperation } from "../events/role.ts";
import { projector } from "../projector.ts";
export class Role extends AggregateRoot<EventStoreFactory> {
static override readonly name = "role";
id!: string;
name!: string;
permissions: { [resource: string]: Set<string> } = {};
createdAt!: Date;
updatedAt!: Date;
// -------------------------------------------------------------------------
// Factories
// -------------------------------------------------------------------------
static #reducer = makeAggregateReducer(Role);
static create(data: RoleCreatedData, meta: Auditor): Role {
return new Role().push({
type: "role:created",
data,
meta,
});
}
static async getById(stream: string): Promise<Role | undefined> {
return this.$store.reduce({ name: "role", stream, reducer: this.#reducer });
}
// -------------------------------------------------------------------------
// Reducer
// -------------------------------------------------------------------------
override with(event: EventStoreFactory["$events"][number]["$record"]): void {
switch (event.type) {
case "role:created": {
this.id = event.stream;
this.createdAt = getDate(event.created);
this.updatedAt = getDate(event.created);
break;
}
case "role:name-set": {
this.name = event.data;
this.updatedAt = getDate(event.created);
break;
}
case "role:permissions-set": {
for (const operation of event.data) {
if (operation.type === "grant") {
if (this.permissions[operation.resource] === undefined) {
this.permissions[operation.resource] = new Set();
}
this.permissions[operation.resource].add(operation.action);
}
if (operation.type === "deny") {
if (operation.action === undefined) {
delete this.permissions[operation.resource];
} else {
this.permissions[operation.resource]?.delete(operation.action);
}
}
}
break;
}
}
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
setName(name: string, meta: Auditor): this {
return this.push({
type: "role:name-set",
stream: this.id,
data: name,
meta,
});
}
setPermissions(operations: RolePermissionOperation[], meta: Auditor): this {
return this.push({
type: "role:permissions-set",
stream: this.id,
data: operations,
meta,
});
}
}
/*
|--------------------------------------------------------------------------------
| Projectors
|--------------------------------------------------------------------------------
*/
projector.on("role:created", async ({ stream, data: { name, permissions } }) => {
await db.collection("roles").insertOne({
id: stream,
name,
permissions: permissions.reduce(
(map, permission) => {
map[permission.resource] = permission.actions;
return map;
},
{} as Record<string, string[]>,
),
});
});

View File

@@ -0,0 +1,25 @@
import { EventStore } from "@valkyr/event-store";
import { MongoAdapter } from "@valkyr/event-store/mongo";
import { container } from "~libraries/database/container.ts";
import { aggregates } from "./aggregates/mod.ts";
import { events } from "./events/mod.ts";
import { projector } from "./projector.ts";
export const eventStore = new EventStore({
adapter: new MongoAdapter(() => container.get("client"), "balto:event-store"),
events,
aggregates,
snapshot: "auto",
});
eventStore.onEventsInserted(async (records, { batch }) => {
if (batch !== undefined) {
await projector.pushMany(batch, records);
} else {
for (const record of records) {
await projector.push(record, { hydrated: false, outdated: false });
}
}
});

View File

@@ -0,0 +1,29 @@
import { event } from "@valkyr/event-store";
import { email, name, phone } from "relay/schemas";
import z from "zod";
import { auditor } from "./auditor.ts";
const created = z.discriminatedUnion([
z.object({
type: z.literal("admin"),
}),
z.object({
type: z.literal("consultant"),
}),
z.object({
type: z.literal("organization"),
organizationId: z.string(),
}),
]);
export default [
event.type("account:created").data(created).meta(auditor),
event.type("account:avatar:added").data(z.string()).meta(auditor),
event.type("account:name:added").data(name).meta(auditor),
event.type("account:email:added").data(email).meta(auditor),
event.type("account:phone:added").data(phone).meta(auditor),
event.type("account:role:added").data(z.string()).meta(auditor),
];
export type AccountCreatedData = z.infer<typeof created>;

View File

@@ -0,0 +1,7 @@
import z from "zod";
export const auditor = z.object({
accountId: z.string(),
});
export type Auditor = z.infer<typeof auditor>;

View File

@@ -0,0 +1,30 @@
import { event } from "@valkyr/event-store";
import z from "zod";
const identity = z.discriminatedUnion([
z.object({
type: z.literal("admin"),
accountId: z.string(),
}),
z.object({
type: z.literal("consultant"),
accountId: z.string(),
}),
z.object({
type: z.literal("organization"),
organizationId: z.string(),
accountId: z.string(),
}),
]);
export default [
event.type("code:created").data(
z.object({
value: z.string(),
identity,
}),
),
event.type("code:claimed"),
];
export type CodeIdentity = z.infer<typeof identity>;

View File

@@ -0,0 +1,11 @@
import { EventFactory } from "@valkyr/event-store";
import account from "./account.ts";
import code from "./code.ts";
import organization from "./organization.ts";
import role from "./role.ts";
import strategy from "./strategy.ts";
export const events = new EventFactory([...account, ...code, ...organization, ...role, ...strategy]);
export type EventStoreFactory = typeof events;

View File

@@ -0,0 +1,11 @@
import { event } from "@valkyr/event-store";
import z from "zod";
import { auditor } from "./auditor.ts";
export default [
event
.type("organization:created")
.data(z.object({ name: z.string() }))
.meta(auditor),
];

View File

@@ -0,0 +1,37 @@
import { event } from "@valkyr/event-store";
import z from "zod";
import { auditor } from "./auditor.ts";
const created = z.object({
name: z.string(),
permissions: z.array(
z.object({
resource: z.string(),
actions: z.array(z.string()),
}),
),
});
const operation = z.discriminatedUnion([
z.object({
type: z.literal("grant"),
resource: z.string(),
action: z.string(),
}),
z.object({
type: z.literal("deny"),
resource: z.string(),
action: z.string().optional(),
}),
]);
export default [
event.type("role:created").data(created).meta(auditor),
event.type("role:name-set").data(z.string()).meta(auditor),
event.type("role:permissions-set").data(z.array(operation)).meta(auditor),
];
export type RoleCreatedData = z.infer<typeof created>;
export type RolePermissionOperation = z.infer<typeof operation>;

View File

@@ -0,0 +1,13 @@
import { event } from "@valkyr/event-store";
import z from "zod";
import { auditor } from "./auditor.ts";
export default [
event.type("strategy:email:added").data(z.string()).meta(auditor),
event.type("strategy:passkey:added").meta(auditor),
event
.type("strategy:password:added")
.data(z.object({ alias: z.string(), password: z.string() }))
.meta(auditor),
];

View File

@@ -0,0 +1,2 @@
export * from "./event-store.ts";
export * from "./projector.ts";

View File

@@ -0,0 +1,5 @@
import { Projector } from "@valkyr/event-store";
import { EventStoreFactory } from "./events/mod.ts";
export const projector = new Projector<EventStoreFactory>();

View File

@@ -0,0 +1,48 @@
import { HexValue } from "./color/hex.ts";
import { type BGColor, type Color, hexToBgColor, hexToColor, type Modifier, styles } from "./color/styles.ts";
export const chalk = {
color(hex: HexValue): (value: string) => string {
const color = hexToColor(hex);
return (value: string) => `${color}${value}${styles.modifier.reset}`;
},
bgColor(hex: HexValue): (value: string) => string {
const color = hexToBgColor(hex);
return (value: string) => `${color}${value}${styles.modifier.reset}`;
},
} as Chalk;
for (const key in styles.modifier) {
chalk[key as Modifier] = function (value: string) {
return toModifiedValue(key as Modifier, value);
};
}
for (const key in styles.color) {
chalk[key as Color] = function (value: string) {
return toColorValue(key as Color, value);
};
}
for (const key in styles.bgColor) {
chalk[key as BGColor] = function (value: string) {
return toBGColorValue(key as BGColor, value);
};
}
function toModifiedValue(key: Modifier, value: string): string {
return `${styles.modifier[key]}${value}${styles.modifier.reset}`;
}
function toColorValue(key: Color, value: string): string {
return `${styles.color[key]}${value}${styles.modifier.reset}`;
}
function toBGColorValue(key: BGColor, value: string): string {
return `${styles.bgColor[key]}${value}${styles.modifier.reset}`;
}
type Chalk = Record<Modifier | Color | BGColor, (value: string) => string> & {
color(hex: HexValue): (value: string) => string;
bgColor(hex: HexValue): (value: string) => string;
};

View File

@@ -0,0 +1,28 @@
import { rgbToAnsi256 } from "./rgb.ts";
/**
* Convert provided hex value to closest 256-Color value.
*
* @param hex - Hex to convert.
*/
export function hexToAnsi256(hex: HexValue) {
const { r, g, b } = hexToRGB(hex);
return rgbToAnsi256(r, g, b);
}
/**
* Take a hex value and return its RGB values.
*
* @param hex - Hex to convert to RGB
* @returns
*/
export function hexToRGB(hex: HexValue): { r: number; g: number; b: number } {
return {
r: parseInt(hex.slice(1, 3), 16),
g: parseInt(hex.slice(3, 5), 16),
b: parseInt(hex.slice(5, 7), 16),
};
}
export type HexValue =
`#${string | number}${string | number}${string | number}${string | number}${string | number}${string | number}`;

View File

@@ -0,0 +1,24 @@
/**
* Convert RGB to the nearest 256-color ANSI value
*
* @param r - Red value.
* @param g - Green value.
* @param b - Blue value.
*/
export function rgbToAnsi256(r: number, g: number, b: number): number {
if (r === g && g === b) {
if (r < 8) return 16;
if (r > 248) return 231;
return Math.round(((r - 8) / 247) * 24) + 232;
}
// Map RGB to 6×6×6 color cube (16231)
const conv = (val: number) => Math.round(val / 51);
const ri = conv(r);
const gi = conv(g);
const bi = conv(b);
return 16 + 36 * ri + 6 * gi + bi;
}
export type RGB = { r: number; g: number; b: number };

View File

@@ -0,0 +1,76 @@
import { hexToAnsi256, HexValue } from "./hex.ts";
import { toEscapeSequence } from "./utilities.ts";
export const styles = {
modifier: {
reset: toEscapeSequence(0), // Reset to normal
bold: toEscapeSequence(1), // Bold text
dim: toEscapeSequence(2), // Dim text
italic: toEscapeSequence(3), // Italic text
underline: toEscapeSequence(4), // Underlined text
overline: toEscapeSequence(53), // Overline text
inverse: toEscapeSequence(7), // Inverse
hidden: toEscapeSequence(8), // Hidden text
strikethrough: toEscapeSequence(9), // Strikethrough
},
color: {
black: toEscapeSequence(30), // Black color
red: toEscapeSequence(31), // Red color
green: toEscapeSequence(32), // Green color
yellow: toEscapeSequence(33), // Yellow color
blue: toEscapeSequence(34), // Blue color
magenta: toEscapeSequence(35), // Magenta color
cyan: toEscapeSequence(36), // Cyan color
white: toEscapeSequence(37), // White color
orange: hexToColor("#FFA500"),
// Bright colors
blackBright: toEscapeSequence(90),
gray: toEscapeSequence(90), // Alias for blackBright
grey: toEscapeSequence(90), // Alias for blackBright
redBright: toEscapeSequence(91),
greenBright: toEscapeSequence(92),
yellowBright: toEscapeSequence(93),
blueBright: toEscapeSequence(94),
magentaBright: toEscapeSequence(95),
cyanBright: toEscapeSequence(96),
whiteBright: toEscapeSequence(97),
},
bgColor: {
bgBlack: toEscapeSequence(40),
bgRed: toEscapeSequence(41),
bgGreen: toEscapeSequence(42),
bgYellow: toEscapeSequence(43),
bgBlue: toEscapeSequence(44),
bgMagenta: toEscapeSequence(45),
bgCyan: toEscapeSequence(46),
bgWhite: toEscapeSequence(47),
bgOrange: hexToBgColor("#FFA500"),
// Bright background colors
bgBlackBright: toEscapeSequence(100),
bgGray: toEscapeSequence(100), // Alias for bgBlackBright
bgGrey: toEscapeSequence(100), // Alias for bgBlackBright
bgRedBright: toEscapeSequence(101),
bgGreenBright: toEscapeSequence(102),
bgYellowBright: toEscapeSequence(103),
bgBlueBright: toEscapeSequence(104),
bgMagentaBright: toEscapeSequence(105),
bgCyanBright: toEscapeSequence(106),
bgWhiteBright: toEscapeSequence(107),
},
};
export function hexToColor(hex: HexValue): string {
return toEscapeSequence(`38;5;${hexToAnsi256(hex)}`); // Foreground color
}
export function hexToBgColor(hex: HexValue): string {
return toEscapeSequence(`48;5;${hexToAnsi256(hex)}`); // Background color
}
export type Modifier = keyof typeof styles.modifier;
export type Color = keyof typeof styles.color;
export type BGColor = keyof typeof styles.bgColor;

View File

@@ -0,0 +1,3 @@
export function toEscapeSequence(value: string | number): `\x1b[${string}m` {
return `\x1b[${value}m`;
}

View File

@@ -0,0 +1,5 @@
import { getArgsVariable } from "~libraries/config/mod.ts";
export const config = {
level: getArgsVariable("LOG_LEVEL", "info"),
};

View File

@@ -0,0 +1,19 @@
import { EventValidationError } from "@valkyr/event-store";
import type { Level } from "../level.ts";
import { getTracedAt } from "../stack.ts";
export function toEventStoreLog(arg: any, level: Level): any {
if (arg instanceof EventValidationError) {
const obj: any = {
origin: "EventStore",
message: arg.message,
at: getTracedAt(arg.stack, "/api/domains"),
data: arg.errors,
};
if (level === "debug") {
obj.stack = arg.stack;
}
return obj;
}
}

View File

@@ -0,0 +1,18 @@
import { ServerError } from "@spec/relay";
import type { Level } from "../level.ts";
import { getTracedAt } from "../stack.ts";
export function toServerLog(arg: any, level: Level): any {
if (arg instanceof ServerError) {
const obj: any = {
message: arg.message,
data: arg.data,
at: getTracedAt(arg.stack, "/api/domains"),
};
if (level === "debug") {
obj.stack = arg.stack;
}
return obj;
}
}

View File

@@ -0,0 +1,8 @@
export const logLevel = {
debug: 0,
info: 1,
warning: 2,
error: 3,
};
export type Level = "debug" | "error" | "warning" | "info";

View File

@@ -0,0 +1,95 @@
import { chalk } from "./chalk.ts";
import { type Level, logLevel } from "./level.ts";
export class Logger {
#level: Level = "info";
#config: Config;
constructor(config: Config) {
this.#config = config;
}
get #prefix(): [string?] {
if (this.#config.prefix !== undefined) {
return [chalk.bold(chalk.green(this.#config.prefix))];
}
return [];
}
/**
* Set the highest logging level in the order of debug, info, warn, error.
*
* When value is 'info', info, warn and error will be logged and debug
* will be ignored.
*
* @param value Highest log level.
*/
level(value: Level): this {
this.#level = value;
return this;
}
/**
* Returns a new logger instance with the given name as prefix.
*
* @param name - Prefix name.
*/
prefix(name: string): Logger {
return new Logger({ prefix: name, loggers: this.#config.loggers }).level(this.#level);
}
/**
* Emit a debug message to terminal.
*/
debug(...args: any[]) {
if (this.#isLevelEnabled(0)) {
console.log(new Date(), chalk.bold("Debug"), ...this.#prefix, ...args.map(this.#toFormattedArg));
}
}
/**
* Emit a info message to terminal.
*/
info(...args: any[]) {
if (this.#isLevelEnabled(1)) {
console.log(new Date(), chalk.bold(chalk.blue("Info")), ...this.#prefix, ...args.map(this.#toFormattedArg));
}
}
/**
* Emit a warning message to terminal.
*/
warn(...args: any[]) {
if (this.#isLevelEnabled(2)) {
console.log(new Date(), chalk.bold(chalk.orange("Warning")), ...this.#prefix, ...args.map(this.#toFormattedArg));
}
}
/**
* Emit a errpr message to terminal.
*/
error(...args: any[]) {
if (this.#isLevelEnabled(3)) {
console.log(new Date(), chalk.bold(chalk.red("Error")), ...this.#prefix, ...args.map(this.#toFormattedArg));
}
}
#isLevelEnabled(level: 0 | 1 | 2 | 3): boolean {
return level >= logLevel[this.#level];
}
#toFormattedArg = (arg: any): string => {
for (const logger of this.#config.loggers) {
const res = logger(arg, this.#level);
if (res !== undefined) {
return res;
}
}
return arg;
};
}
type Config = {
prefix?: string;
loggers: ((arg: any, level: Level) => any)[];
};

View File

@@ -0,0 +1,7 @@
import { toEventStoreLog } from "./format/event-store.ts";
import { toServerLog } from "./format/server.ts";
import { Logger } from "./logger.ts";
export const logger = new Logger({
loggers: [toServerLog, toEventStoreLog],
});

View File

@@ -0,0 +1,20 @@
/**
* Fetch the most closest relevant error from the local code base so it can
* be more easily traced to its source.
*
* @param stack - Error stack.
* @param search - Relevant stack line search value.
*/
export function getTracedAt(stack: string | undefined, search: string): string | undefined {
if (stack === undefined) {
return undefined;
}
const firstMatch = stack.split("\n").find((line) => line.includes(search));
if (firstMatch === undefined) {
return undefined;
}
return firstMatch
.replace(/^.*?(file:\/\/\/)/, "$1")
.replace(/\)$/, "")
.trim();
}

View File

@@ -0,0 +1,11 @@
import { idIndex } from "~libraries/database/id.ts";
import { register } from "~libraries/database/registrar.ts";
import { db } from "../database.ts";
await register(db.db, [
{
name: "accounts",
indexes: [idIndex],
},
]);

View File

@@ -0,0 +1,6 @@
import { db, takeOne } from "../database.ts";
import { type AccountSchema, fromAccountDriver } from "./schema.ts";
export async function getAccountById(id: string): Promise<AccountSchema | undefined> {
return db.collection("accounts").find({ id }).toArray().then(fromAccountDriver).then(takeOne);
}

View File

@@ -0,0 +1,36 @@
import { z } from "zod";
const account = z.object({
id: z.uuid(),
name: z.object({
given: z.string(),
family: z.string(),
}),
email: z.email(),
});
/*
|--------------------------------------------------------------------------------
| Parsers
|--------------------------------------------------------------------------------
*/
const select = account;
const insert = account;
export function toAccountDriver(documents: unknown): AccountInsert {
return insert.parse(documents);
}
export function fromAccountDriver(documents: unknown[]): AccountSchema[] {
return documents.map((document) => select.parse(document));
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type AccountSchema = z.infer<typeof select>;
export type AccountInsert = z.infer<typeof insert>;

View File

@@ -0,0 +1,12 @@
import { config } from "~config";
import { getDatabaseAccessor } from "~libraries/database/accessor.ts";
import { AccountInsert } from "./account/schema.ts";
export const db = getDatabaseAccessor<{
accounts: AccountInsert;
}>(`${config.name}:read-store`);
export function takeOne<TDocument>(documents: TDocument[]): TDocument | undefined {
return documents[0];
}

View File

@@ -0,0 +1,3 @@
export * from "./account/methods.ts";
export * from "./account/schema.ts";
export * from "./database.ts";

415
api/libraries/server/api.ts Normal file
View File

@@ -0,0 +1,415 @@
import {
BadRequestError,
ForbiddenError,
InternalServerError,
NotFoundError,
NotImplementedError,
Route,
RouteMethod,
ServerError,
type ServerErrorResponse,
UnauthorizedError,
ZodValidationError,
} from "@spec/relay";
import { treeifyError } from "zod";
import { logger } from "~libraries/logger/mod.ts";
import { getRequestContext } from "./context.ts";
import { req } from "./request.ts";
const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
export class Api {
readonly #index = new Map<string, Route>();
/**
* Route maps funneling registered routes to the specific methods supported by
* the relay instance.
*/
readonly routes: Routes = {
POST: [],
GET: [],
PUT: [],
PATCH: [],
DELETE: [],
};
/**
* List of paths in the '${method} ${path}' format allowing us to quickly throw
* errors if a duplicate route path is being added.
*/
readonly #paths = new Set<string>();
/**
* Instantiate a new Api instance.
*
* @param routes - Initial list of routes to register with the api.
*/
constructor(routes: Route[] = []) {
this.register(routes);
}
/**
* Register relays with the API instance allowing for decoupled registration
* of server side handling of relay contracts.
*
* @param routes - Relays to register with the instance.
*/
register(routes: Route[]): this {
const methods: (keyof typeof this.routes)[] = [];
for (const route of routes) {
const path = `${route.method} ${route.path}`;
if (this.#paths.has(path)) {
throw new Error(`Router > Path ${path} already exists`);
}
this.#paths.add(path);
this.routes[route.method].push(route);
methods.push(route.method);
this.#index.set(`${route.method} ${route.path}`, route);
}
for (const method of methods) {
this.routes[method].sort(byStaticPriority);
}
return this;
}
/**
* Executes request and returns a `Response` instance.
*
* @param request - REST request to pass to a route handler.
*/
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// ### Route
// Locate a route matching the incoming request method and path.
const resolved = this.#getResolvedRoute(request.method, url.pathname);
if (resolved === undefined) {
return toResponse(
new NotFoundError(`Invalid routing path provided for ${request.url}`, {
method: request.method,
url: request.url,
}),
request,
);
}
// ### Handle
// Execute request and return a response.
const response = await this.#getRouteResponse(resolved, request).catch((error) =>
this.#getErrorResponse(error, resolved.route, request),
);
return response;
}
/**
* Attempt to resolve a route based on the given method and pathname.
*
* @param method - HTTP method.
* @param url - HTTP request url.
*/
#getResolvedRoute(method: string, url: string): ResolvedRoute | undefined {
assertMethod(method);
for (const route of this.routes[method]) {
if (route.match(url) === true) {
return { route, params: route.getParsedParams(url) };
}
}
}
/**
* Resolve the request on the given route and return a `Response` instance.
*
* @param resolved - Route and paramter details resolved for the request.
* @param request - Request instance to resolve.
*/
async #getRouteResponse({ route, params }: ResolvedRoute, request: Request): Promise<Response> {
const url = new URL(request.url);
// ### Args
// Arguments is passed to every route handler and provides a suite of functionality
// and request data.
const args: any[] = [];
// ### Input
// Generate route input which contains a map fo params, query, and/or body. If
// none of these are present then the input is not added to the final argument
// context of the handler.
const input: {
params?: object;
query?: object;
body?: unknown;
} = {
params: undefined,
query: undefined,
body: undefined,
};
// ### Access
// Check the access requirements of the route and run any additional checks
// if nessesary before proceeding with further request handling.
// 1. All routes needs access assignment, else we consider it an internal error.
// 2. If access requires a session we throw Unauthorized if the request is not authenticated.
// 3. If access is an array of access resources, we check that each resources can be
// accessed by the request.
if (route.state.access === undefined) {
return toResponse(
new InternalServerError(`Route '${route.method} ${route.path}' is missing access assignment.`),
request,
);
}
if (route.state.access === "session" && req.isAuthenticated === false) {
return toResponse(new UnauthorizedError(), request);
}
if (Array.isArray(route.state.access)) {
for (const hasAccess of route.state.access) {
if (hasAccess() === false) {
return toResponse(new ForbiddenError(), request);
}
}
}
// ### Params
// If the route has params we want to coerce the values to the expected types.
if (route.state.params !== undefined) {
const result = await route.state.params.safeParseAsync(params);
if (result.success === false) {
return toResponse(new ZodValidationError("Invalid request params", treeifyError(result.error)), request);
}
input.params = result.data;
}
// ### Query
// If the route has a query schema we need to validate and parse the query.
if (route.state.query !== undefined) {
const result = await route.state.query.safeParseAsync(toQuery(url.searchParams) ?? {});
if (result.success === false) {
return toResponse(new ZodValidationError("Invalid request query", treeifyError(result.error)), request);
}
input.query = result.data;
}
// ### Body
// If the route has a body schema we need to validate and parse the body.
if (route.state.body !== undefined) {
const body = await this.#getRequestBody(request);
const result = await route.state.body.safeParseAsync(body);
if (result.success === false) {
return toResponse(new ZodValidationError("Invalid request body", treeifyError(result.error)), request);
}
input.body = result.data;
}
if (input.params !== undefined || input.query !== undefined || input.body !== undefined) {
args.push(input);
}
// ### Context
// Request context pass to every route as the last argument.
args.push(getRequestContext(request));
// ### Handler
// Execute the route handler and apply the result.
if (route.state.handle === undefined) {
return toResponse(new NotImplementedError(`Path '${route.method} ${route.path}' is not implemented.`), request);
}
return toResponse(await route.state.handle(...args), request);
}
#getErrorResponse(error: unknown, route: Route, request: Request): Response {
if (route?.state.hooks?.onError !== undefined) {
return route.state.hooks.onError(error);
}
if (error instanceof ServerError) {
return toResponse(error, request);
}
logger.error(error);
if (error instanceof Error) {
return toResponse(new InternalServerError(error.message), request);
}
return toResponse(new InternalServerError(), request);
}
/**
* Resolves request body and returns it.
*
* @param request - Request to resolve body from.
* @param files - Files to populate if present.
*/
async #getRequestBody(request: Request): Promise<Record<string, unknown>> {
let body: Record<string, unknown> = {};
const type = request.headers.get("content-type");
if (!type || request.method === "GET") {
return body;
}
if (type.includes("json")) {
body = await request.json();
}
if (type.includes("application/x-www-form-urlencoded") || type.includes("multipart/form-data")) {
try {
const formData = await request.formData();
for (const [name, value] of Array.from(formData.entries())) {
body[name] = value;
}
} catch (error) {
logger.error(error);
throw new BadRequestError(`Malformed FormData`, { error });
}
}
return body;
}
}
/*
|--------------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------------
*/
/**
* Assert that the given method string is a valid routing method.
*
* @param candidate - Method candidate.
*/
function assertMethod(candidate: string): asserts candidate is RouteMethod {
if (!SUPPORTED_MEHODS.includes(candidate)) {
throw new Error(`Router > Unsupported method '${candidate}'`);
}
}
/**
* Sorting method for routes to ensure that static properties takes precedence
* for when a route is matched against incoming requests.
*
* @param a - Route A
* @param b - Route B
*/
function byStaticPriority(a: Route, b: Route) {
const aSegments = a.path.split("/");
const bSegments = b.path.split("/");
const maxLength = Math.max(aSegments.length, bSegments.length);
for (let i = 0; i < maxLength; i++) {
const aSegment = aSegments[i] || "";
const bSegment = bSegments[i] || "";
const isADynamic = aSegment.startsWith(":");
const isBDynamic = bSegment.startsWith(":");
if (isADynamic !== isBDynamic) {
return isADynamic ? 1 : -1;
}
if (isADynamic === false && aSegment !== bSegment) {
return aSegment.localeCompare(bSegment);
}
}
return a.path.localeCompare(b.path);
}
/**
* Resolve and return query object from the provided search parameters, or undefined
* if the search parameters does not have any entries.
*
* @param searchParams - Search params to create a query object from.
*/
function toQuery(searchParams: URLSearchParams): object | undefined {
if (searchParams.size === 0) {
return undefined;
}
const result: Record<string, string> = {};
for (const [key, value] of searchParams.entries()) {
result[key] = value;
}
return result;
}
/**
* Takes a server side request result and returns a fetch Response.
*
* @param result - Result to send back as a Response.
* @param request - Request instance.
*/
export function toResponse(result: unknown, request: Request): Response {
const method = request.method;
if (result instanceof Response) {
if (method === "HEAD") {
return new Response(null, {
status: result.status,
statusText: result.statusText,
headers: new Headers(result.headers),
});
}
return result;
}
if (result instanceof ServerError) {
const body = JSON.stringify({
error: {
status: result.status,
message: result.message,
data: result.data,
},
} satisfies ServerErrorResponse);
return new Response(method === "HEAD" ? null : body, {
statusText: result.message || "Internal Server Error",
status: result.status || 500,
headers: {
"content-type": "application/json",
},
});
}
const body = JSON.stringify({
data: result ?? null,
});
return new Response(method === "HEAD" ? null : body, {
status: 200,
headers: {
"content-type": "application/json",
},
});
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type Routes = {
POST: Route[];
GET: Route[];
PUT: Route[];
PATCH: Route[];
DELETE: Route[];
};
type ResolvedRoute = {
route: Route;
params: any;
};

View File

@@ -0,0 +1,16 @@
import { RouteContext } from "@spec/relay";
export function getRequestContext(request: Request): RouteContext {
return {
request,
};
}
declare module "@spec/relay" {
interface RouteContext {
/**
* Current request instance being handled.
*/
request: Request;
}
}

View File

@@ -0,0 +1,5 @@
export * from "./api.ts";
export * from "./context.ts";
export * from "./modules.ts";
export * from "./request.ts";
export * from "./storage.ts";

View File

@@ -0,0 +1,40 @@
import { Route } from "@spec/relay";
/**
* Resolve and return all routes that has been created under any 'routes'
* folders that can be found under the given path.
*
* If the filter is empty, all paths are resolved, otherwise only paths
* declared in the array is resolved.
*
* @param path - Path to resolve routes from.
* @param filter - List of modules to include.
* @param routes - List of routes that has been resolved.
*/
export async function resolveRoutes(path: string, routes: Route[] = []): Promise<Route[]> {
for await (const entry of Deno.readDir(path)) {
if (entry.isDirectory === true) {
await loadRoutes(`${path}/${entry.name}/routes`, routes, [name]);
}
}
return routes;
}
async function loadRoutes(path: string, routes: Route[], modules: string[]): Promise<void> {
for await (const entry of Deno.readDir(path)) {
if (entry.isDirectory === true) {
await loadRoutes(`${path}/${entry.name}`, routes, [...modules, entry.name]);
} else {
if (!entry.name.endsWith(".ts") || entry.name.endsWith("i9n.ts")) {
continue;
}
const { default: route } = (await import(`${path}/${entry.name}`)) as { default: Route };
if (route instanceof Route === false) {
throw new Error(
`Router Violation: Could not load '${path}/${entry.name}' as it does not export a default Route instance.`,
);
}
routes.push(route);
}
}
}

View File

@@ -0,0 +1,57 @@
import { asyncLocalStorage } from "./storage.ts";
export const req = {
get store() {
const store = asyncLocalStorage.getStore();
if (store === undefined) {
throw new Error("Request > AsyncLocalStorage not defined.");
}
return store;
},
get socket() {
return this.store.socket;
},
/**
* Get store that is potentially undefined.
* Typically used when utility functions might run in and out of request scope.
*/
get unsafeStore() {
return asyncLocalStorage.getStore();
},
/**
* Check if the request is authenticated.
*/
get isAuthenticated() {
return this.session !== undefined;
},
/**
* Get current session.
*/
get session() {
return this.store.session;
},
/**
* Gets the meta information stored in the request.
*/
get info() {
return this.store.info;
},
/**
* Sends a JSON-RPC 2.0 notification to the request if sent through a
* WebSocket connection.
*
* @param method - Method to send notification to.
* @param params - Params to pass to the method.
*/
notify(method: string, params: any): void {
this.socket?.send(JSON.stringify({ jsonrpc: "2.0", method, params }));
},
} as const;
export type ReqContext = typeof req;

View File

@@ -0,0 +1,16 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { Session } from "~libraries/auth/mod.ts";
export const asyncLocalStorage = new AsyncLocalStorage<{
session?: Session;
info: {
method: string;
start: number;
end?: number;
};
socket?: WebSocket;
response: {
headers: Headers;
};
}>();

View File

@@ -0,0 +1,81 @@
import type { Params } from "@valkyr/json-rpc";
import { Sockets } from "./sockets.ts";
export class Channels {
readonly #channels = new Map<string, Sockets>();
/**
* Add a new channel.
*
* @param channel
*/
add(channel: string): this {
this.#channels.set(channel, new Sockets());
return this;
}
/**
* Deletes a channel.
*
* @param channel
*/
del(channel: string): this {
this.#channels.delete(channel);
return this;
}
/**
* Add socket to the given channel. If the channel does not exist it is
* automatically created.
*
* @param channel - Channel to add socket to.
* @param socket - Socket to add to the channel.
*/
join(channel: string, socket: WebSocket): this {
const sockets = this.#channels.get(channel);
if (sockets === undefined) {
this.#channels.set(channel, new Sockets().add(socket));
} else {
sockets.add(socket);
}
return this;
}
/**
* Remove a socket from the given channel.
*
* @param channel - Channel to leave.
* @param socket - Socket to remove from the channel.
*/
leave(channel: string, socket: WebSocket): this {
this.#channels.get(channel)?.del(socket);
return this;
}
/**
* Sends a JSON-RPC notification to all sockets in given channel.
*
* @param channel - Channel to emit method to.
* @param method - Method to send the notification to.
* @param params - Message data to send to the clients.
*/
notify(channel: string, method: string, params: Params): this {
this.#channels.get(channel)?.notify(method, params);
return this;
}
/**
* Transmits data to all registered WebSocket connections in the given channel.
* Data can be a string, a Blob, an ArrayBuffer, or an ArrayBufferView.
*
* @param channel - Channel to emit message to.
* @param data - Data to send to each connected socket in the channel.
*/
send(channel: string, data: string | ArrayBufferLike | Blob | ArrayBufferView): this {
this.#channels.get(channel)?.send(data);
return this;
}
}
export const channels = new Channels();

View File

@@ -0,0 +1 @@
export { Sockets } from "./sockets.ts";

View File

@@ -0,0 +1,49 @@
import type { Params } from "@valkyr/json-rpc";
export class Sockets {
readonly #sockets = new Set<WebSocket>();
/**
* Add a socket to the pool.
*
* @param socket - WebSocket to add.
*/
add(socket: WebSocket): this {
this.#sockets.add(socket);
return this;
}
/**
* Remove a socket from the pool.
*
* @param socket - WebSocket to remove.
*/
del(socket: WebSocket): this {
this.#sockets.delete(socket);
return this;
}
/**
* Sends a JSON-RPC notification to all connected sockets.
*
* @param method - Method to send the notification to.
* @param params - Message data to send to the clients.
*/
notify(method: string, params: Params): this {
this.send(JSON.stringify({ jsonrpc: "2.0", method, params }));
return this;
}
/**
* Transmits data to all registered WebSocket connections. Data can be a string,
* a Blob, an ArrayBuffer, or an ArrayBufferView.
*
* @param data - Data to send to each connected socket.
*/
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): this {
this.#sockets.forEach((socket) => socket.send(data));
return this;
}
}
export const sockets = new Sockets();

View File

@@ -0,0 +1,60 @@
import { toJsonRpc } from "@valkyr/json-rpc";
import { Session } from "~libraries/auth/mod.ts";
import { logger } from "~libraries/logger/mod.ts";
import { sockets } from "./sockets.ts";
export function upgrade(request: Request, session?: Session) {
const { socket, response } = Deno.upgradeWebSocket(request);
socket.addEventListener("open", () => {
logger.prefix("Socket").info("socket connected", { session });
sockets.add(socket);
});
socket.addEventListener("close", () => {
logger.prefix("Socket").info("socket disconnected", { session });
sockets.del(socket);
});
socket.addEventListener("message", (event) => {
if (event.data === "ping") {
return;
}
const body = toJsonRpc(event.data);
logger.prefix("Socket").info(body);
asyncLocalStorage.run(
{
session,
info: {
method: body.method!,
start: Date.now(),
},
socket,
response: {
headers: new Headers(),
},
},
async () => {
api
.handleCommand(body)
.then((response) => {
if (response !== undefined) {
logger.info({ response });
socket.send(JSON.stringify(response));
}
})
.catch((error) => {
logger.info({ error });
socket.send(JSON.stringify(error));
});
},
);
});
return response;
}

View File

@@ -0,0 +1,4 @@
export const config = {
mongodb: "mongo:8.0.3",
postgres: "postgres:17",
};

View File

@@ -0,0 +1,154 @@
import { getAvailablePort } from "@std/net";
import cookie from "cookie";
import { auth, Session } from "~libraries/auth/mod.ts";
import { Code } from "~libraries/code/aggregates/code.ts";
import { handler } from "~libraries/server/handler.ts";
import { Api, QueryMethod } from "../.generated/api.ts";
export class ApiTestContainer {
#server?: Deno.HttpServer;
#client?: Api;
#cookie?: string;
#session?: Session;
/*
|--------------------------------------------------------------------------------
| Accessors
|--------------------------------------------------------------------------------
*/
get accountId(): string | undefined {
if (this.#session?.valid === true) {
return this.#session.accountId;
}
}
get client() {
if (this.#client === undefined) {
throw new Error("ApiContainer > .start() has not been executed.");
}
return this.#client;
}
/*
|--------------------------------------------------------------------------------
| Lifecycle
|--------------------------------------------------------------------------------
*/
async start(): Promise<this> {
const port = await getAvailablePort();
this.#server = await Deno.serve({ port, hostname: "127.0.0.1" }, handler);
this.#client = makeApiClient(port, {
onBeforeRequest: (headers: Headers) => {
if (this.#cookie !== undefined) {
headers.set("cookie", this.#cookie);
}
},
onAfterResponse: (response) => {
const cookie = response.headers.get("set-cookie");
if (cookie !== null) {
this.#cookie = cookie;
}
},
});
return this;
}
async stop() {
await this.#server?.shutdown();
}
/*
|--------------------------------------------------------------------------------
| Utilities
|--------------------------------------------------------------------------------
*/
async authorize(accountId: string): Promise<void> {
const code = await Code.create({ identity: { type: "admin", accountId } }).save();
await this.client.auth.code(accountId, code.id, code.value, {});
this.#session = await this.getSession();
}
async getSession(): Promise<Session | undefined> {
const token = cookie.parse(this.#cookie ?? "").token;
if (token !== undefined) {
const session = await auth.resolve(token);
if (session.valid === true) {
return session;
}
}
}
unauthorize(): void {
this.#cookie = undefined;
}
}
function makeApiClient(
port: number,
{
onBeforeRequest,
onAfterResponse,
}: {
onBeforeRequest: (headers: Headers) => void;
onAfterResponse: (response: Response) => void;
},
): Api {
return new Api({
async command(payload) {
const headers = new Headers();
onBeforeRequest(headers);
headers.set("content-type", "application/json");
const response = await fetch(`http://127.0.0.1:${port}/api/v1/command`, {
method: "POST",
headers,
body: JSON.stringify(payload),
});
const text = await response.text();
if (response.status >= 300) {
console.error(
`Command '${payload.method}' responded with error status '${response.status} ${response.statusText}'.`,
);
}
if (response.headers.get("content-type")?.includes("json") === true) {
return JSON.parse(text);
}
},
async query(method: QueryMethod, path: string, query: Record<string, unknown>, body: any = {}) {
const headers = new Headers();
onBeforeRequest(headers);
if (method !== "GET") {
headers.set("content-type", "application/json");
}
const response = await fetch(`http://127.0.0.1:${port}${path}${getSearchQuery(query)}`, {
method,
headers,
body: method === "GET" ? undefined : JSON.stringify(body),
});
onAfterResponse(response);
const text = await response.text();
if (response.status >= 300) {
console.error(`Query '${path}' responded with error status '${response.status} ${response.statusText}'.`);
throw new Error(response.statusText);
}
if (response.headers.get("content-type")?.includes("json") === true) {
return JSON.parse(text);
}
},
});
}
function getSearchQuery(query: Record<string, unknown>): string {
const search: string[] = [];
for (const key in query) {
search.push(`${key}=${query[key]}`);
}
if (search.length === 0) {
return "";
}
return `?${search.join("&")}`;
}

View File

@@ -0,0 +1,41 @@
import { MongoTestContainer } from "@valkyr/testcontainers/mongodb";
import { container } from "~database/container.ts";
import { logger } from "~libraries/logger/mod.ts";
import { bootstrap } from "~libraries/utilities/bootstrap.ts";
import { API_DOMAINS_DIR, API_PACKAGES_DIR } from "~paths";
export class DatabaseTestContainer {
constructor(readonly mongo: MongoTestContainer) {
container.set("client", mongo.client);
}
/*
|--------------------------------------------------------------------------------
| Lifecycle
|--------------------------------------------------------------------------------
*/
async start(): Promise<this> {
logger.prefix("Database").info("DatabaseTestContainer Started");
await bootstrap(API_DOMAINS_DIR);
await bootstrap(API_PACKAGES_DIR);
return this;
}
async truncate() {
const promises: Promise<any>[] = [];
for (const dbName of ["balto:auth", "balto:code", "balto:consultant", "balto:task"]) {
const db = this.mongo.client.db(dbName);
const collections = await db.listCollections().toArray();
promises.push(...collections.map(({ name }) => db.collection(name).deleteMany({})));
}
await Promise.all(promises);
}
async stop() {
logger.prefix("Database").info("DatabaseTestContainer stopped");
}
}

View File

@@ -0,0 +1,178 @@
import { MongoTestContainer } from "@valkyr/testcontainers/mongodb";
import { config } from "../config.ts";
import { ApiTestContainer } from "./api-container.ts";
import { DatabaseTestContainer } from "./database-container.ts";
export class TestContainer {
readonly id = crypto.randomUUID();
// ### Enablers
// A map of services to enable when the TestContainer is started. These toggles
// must be toggled before the container is started.
#with: With = {
mongodb: false,
database: false,
api: false,
};
// ### Needs
#needs: Needs = {
mongodb: [],
database: ["mongodb"],
api: ["mongodb", "database"],
};
// ### Services
// Any services that has been enabled will be running under the following
// assignments. Make sure to .stop any running services to avoid shutdown
// leaks.
#mongodb?: MongoTestContainer;
#database?: DatabaseTestContainer;
#api?: ApiTestContainer;
/*
|--------------------------------------------------------------------------------
| Accessors
|--------------------------------------------------------------------------------
*/
get accountId() {
if (this.#api === undefined) {
throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
}
return this.#api.accountId;
}
get mongodb(): MongoTestContainer {
if (this.#mongodb === undefined) {
throw new Error("TestContainer > .withMongo() must be called before starting the TestContainer.");
}
return this.#mongodb;
}
get database(): DatabaseTestContainer {
if (this.#database === undefined) {
throw new Error("TestContainer > .withDatabase() must be called before starting the TestContainer.");
}
return this.#database;
}
get api() {
if (this.#api === undefined) {
throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
}
return this.#api.client;
}
get authorize() {
if (this.#api === undefined) {
throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
}
return this.#api.authorize.bind(this.#api);
}
get unauthorize() {
if (this.#api === undefined) {
throw new Error("TestContainer > .withApi() must be called before starting the TestContainer.");
}
return this.#api.unauthorize.bind(this.#api);
}
/*
|--------------------------------------------------------------------------------
| Builder
|--------------------------------------------------------------------------------
*/
withMongo(): this {
this.#with.mongodb = true;
return this;
}
withDatabase(): this {
this.#with.database = true;
return this;
}
withApi(): this {
this.#with.api = true;
return this;
}
/*
|--------------------------------------------------------------------------------
| Lifecycle
|--------------------------------------------------------------------------------
*/
async start(): Promise<this> {
const promises: Promise<void>[] = [];
if (this.#isNeeded("mongodb") === true) {
promises.push(
(async () => {
this.#mongodb = await MongoTestContainer.start(config.mongodb);
if (this.#isNeeded("database") === true) {
this.#database = await new DatabaseTestContainer(this.mongodb).start();
}
})(),
);
}
if (this.#isNeeded("api") === true) {
promises.push(
(async () => {
this.#api = await new ApiTestContainer().start();
})(),
);
}
await Promise.all(promises);
return this;
}
async stop(): Promise<this> {
await this.#api?.stop();
await this.#database?.stop();
await this.#mongodb?.stop();
this.#api = undefined;
this.#database = undefined;
this.#mongodb = undefined;
return this;
}
/*
|--------------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------------
*/
#isNeeded(target: keyof With): boolean {
if (this.#with[target] !== false) {
return true;
}
for (const key in this.#needs) {
if (this.#with[key as keyof With] !== false && this.#needs[key as keyof With].includes(target) === true) {
return true;
}
}
return false;
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type Needs = Record<keyof With, (keyof With)[]>;
type With = {
mongodb: boolean;
database: boolean;
api: boolean;
};

View File

@@ -0,0 +1,24 @@
import * as assertSuite from "@std/assert";
import * as bddSuite from "@std/testing/bdd";
import type { TestContainer } from "~libraries/testing/containers/test-container.ts";
import { authorize } from "./utilities/account.ts";
export function describe(name: string, runner: TestRunner): (container: TestContainer) => void {
return (container: TestContainer) =>
bddSuite.describe(name, () => runner(container, bddSuite, assertSuite, { authorize: authorize(container) }));
}
export type TestRunner = (
container: TestContainer,
bdd: {
[key in keyof typeof bddSuite]: (typeof bddSuite)[key];
},
assert: {
[key in keyof typeof assertSuite]: (typeof assertSuite)[key];
},
utils: {
authorize: ReturnType<typeof authorize>;
},
) => void;

View File

@@ -0,0 +1,68 @@
import type { EventData } from "@valkyr/event-store";
import { AccountCreated, AccountEmailAdded } from "~libraries/auth/.generated/events.ts";
import { Account } from "~libraries/auth/aggregates/account.ts";
import { Role } from "~libraries/auth/aggregates/role.ts";
import type { TestContainer } from "~libraries/testing/containers/test-container.ts";
type AuthorizationOptions = {
name?: { family?: string; given?: string };
email?: Partial<EventData<AccountEmailAdded>>;
};
/**
* Return a function which provides the ability to create a new account which
* is authorized and ready to use for testing authorized requests.
*
* @param container - Container to authorize against.
*/
export function authorize(container: TestContainer): AuthorizeFn {
return async (data: EventData<AccountCreated>, { name = {}, email = {} }: AuthorizationOptions = {}) => {
const role = await makeRole(data.type).save();
const account = await Account.create(data, "test")
.addName(name?.family ?? "Doe", name?.given ?? "John", "test")
.addEmail({ value: "john.doe@fixture.none", type: "work", primary: true, verified: true, ...email }, "test")
.addRole(role.id, "test")
.save();
await container.authorize(account.id);
return account;
};
}
function makeRole(type: "admin" | "consultant" | "organization"): Role {
switch (type) {
case "admin": {
return Role.create(
{
name: "Admin",
permissions: [
{ resource: "admin", actions: ["create", "update", "delete"] },
{ resource: "consultant", actions: ["create", "update", "delete"] },
{ resource: "organization", actions: ["create", "update", "delete"] },
],
},
"test",
);
}
case "consultant": {
return Role.create(
{
name: "Consultant",
permissions: [{ resource: "consultant", actions: ["create", "update", "delete"] }],
},
"test",
);
}
case "organization": {
return Role.create(
{
name: "Organization",
permissions: [{ resource: "organization", actions: ["create", "update", "delete"] }],
},
"test",
);
}
}
}
type AuthorizeFn = (data: EventData<AccountCreated>, optional?: AuthorizationOptions) => Promise<Account>;

View File

@@ -0,0 +1,62 @@
/**
* Removes excess indentation caused by using multiline template strings.
*
* Ported from `dedent-js` solution.
*
* @see https://github.com/MartinKolarik/dedent-js
*
* @param templateStrings - Template strings to dedent.
*
* @example
* {
* nested: {
* examples: [
* dedent(`
* I am 8 spaces off from the beginning of this file.
* But I will be 2 spaces based on the trimmed distance
* of the first line.
* `),
* ]
* }
* }
*/
export function dedent(templateStrings: TemplateStringsArray | string, ...values: any[]) {
const matches = [];
const strings = typeof templateStrings === "string" ? [templateStrings] : templateStrings.slice();
// Remove trailing whitespace.
strings[strings.length - 1] = strings[strings.length - 1].replace(/\r?\n([\t ]*)$/, "");
// Find all line breaks to determine the highest common indentation level.
for (let i = 0; i < strings.length; i++) {
const match = strings[i].match(/\n[\t ]+/g);
if (match) {
matches.push(...match);
}
}
// Remove the common indentation from all strings.
if (matches.length) {
const size = Math.min(...matches.map((value) => value.length - 1));
const pattern = new RegExp(`\n[\t ]{${size}}`, "g");
for (let i = 0; i < strings.length; i++) {
strings[i] = strings[i].replace(pattern, "\n");
}
}
// Remove leading whitespace.
strings[0] = strings[0].replace(/^\r?\n/, "");
// Perform interpolation.
let string = strings[0];
for (let i = 0; i < values.length; i++) {
string += values[i] + strings[i + 1];
}
return string;
}

View File

@@ -0,0 +1,41 @@
/**
* Traverse path and look for a `generate.ts` file in each folder found under
* the given path. If a `generate.ts` file is found it is imported so its content
* is executed.
*
* @param path - Path to resolve `generate.ts` files.
* @param filter - Which folders found under the given path to ignore.
*/
export async function generate(path: string, filter: string[] = []): Promise<void> {
const generate: string[] = [];
for await (const entry of Deno.readDir(path)) {
if (entry.isDirectory === true) {
const moduleName = path.split("/").pop();
if (moduleName === undefined) {
continue;
}
if (filter.length > 0 && filter.includes(moduleName) === false) {
continue;
}
const filePath = `${path}/${entry.name}/.tasks/generate.ts`;
if (await hasFile(filePath)) {
generate.push(filePath);
}
}
}
for (const filePath of generate) {
await import(filePath);
}
}
async function hasFile(filePath: string) {
try {
await Deno.lstat(filePath);
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) {
throw err;
}
return false;
}
return true;
}

View File

@@ -0,0 +1,5 @@
import { authenticate } from "@spec/modules/auth/routes/authenticate.ts";
export default authenticate.access("public").handle(async ({ body }) => {
console.log({ body });
});

23
api/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"private": true,
"scripts": {
"start": "deno --allow-all --watch-hmr=routes/ server.ts",
"migrate": "deno run --allow-all .tasks/migrate.ts"
},
"dependencies": {
"@felix/bcrypt": "npm:@jsr/felix__bcrypt@1",
"@spec/modules": "workspace:*",
"@spec/relay": "workspace:*",
"@spec/shared": "workspace:*",
"@std/cli": "npm:@jsr/std__cli@1",
"@std/dotenv": "npm:@jsr/std__dotenv@0.225",
"@std/fs": "npm:@jsr/std__fs@1",
"@std/path": "npm:@jsr/std__path@1",
"@valkyr/auth": "npm:@jsr/valkyr__auth@2",
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.0-beta.5",
"@valkyr/inverse": "npm:@jsr/valkyr__inverse@1",
"cookie": "1",
"mongodb": "6",
"zod": "4"
}
}

93
api/server.ts Normal file
View File

@@ -0,0 +1,93 @@
import { resolve } from "@std/path";
import cookie from "cookie";
import { auth, type Session } from "~libraries/auth/mod.ts";
import { logger } from "~libraries/logger/mod.ts";
import { asyncLocalStorage } from "~libraries/server/mod.ts";
import { Api, resolveRoutes } from "~libraries/server/mod.ts";
import { config } from "./config.ts";
const MODULES_DIR = resolve(import.meta.dirname!, "modules");
const log = logger.prefix("Server");
/*
|--------------------------------------------------------------------------------
| Bootstrap
|--------------------------------------------------------------------------------
*/
await import("./tasks/bootstrap.ts");
/*
|--------------------------------------------------------------------------------
| Service
|--------------------------------------------------------------------------------
*/
const api = new Api(await resolveRoutes(MODULES_DIR));
/*
|--------------------------------------------------------------------------------
| Server
|--------------------------------------------------------------------------------
*/
Deno.serve(
{
port: config.port,
hostname: config.host,
onListen({ port, hostname }) {
logger.prefix("Server").info(`Listening at http://${hostname}:${port}`);
},
},
async (request) => {
const url = new URL(request.url);
// ### Session
let session: Session | undefined;
const token = cookie.parse(request.headers.get("cookie") ?? "").token;
if (token !== undefined) {
const resolved = await auth.resolve(token);
if (resolved.valid === false) {
return new Response(resolved.message, {
status: 401,
headers: {
"set-cookie": cookie.serialize("token", "", config.cookie(0)),
},
});
}
session = resolved;
}
// ### Headers
// Set the default headers.
const headers = new Headers();
// ### Handle
const ts = performance.now();
return asyncLocalStorage.run(
{
session,
info: {
method: request.url,
start: Date.now(),
},
response: {
headers,
},
},
async () => {
return api.fetch(request).finally(() => {
log.info(`${request.method} ${url.pathname} [${((performance.now() - ts) / 1000).toLocaleString()} seconds]`);
});
},
);
},
);

68
api/tasks/bootstrap.ts Normal file
View File

@@ -0,0 +1,68 @@
import { resolve } from "node:path";
import { logger } from "~libraries/logger/mod.ts";
const LIBRARIES_DIR = resolve(import.meta.dirname!, "..", "libraries");
const log = logger.prefix("Bootstrap");
/*
|--------------------------------------------------------------------------------
| Database
|--------------------------------------------------------------------------------
*/
await import("~libraries/database/tasks/bootstrap.ts");
/*
|--------------------------------------------------------------------------------
| Packages
|--------------------------------------------------------------------------------
*/
await bootstrap(LIBRARIES_DIR);
/*
|--------------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------------
*/
/**
* Traverse path and look for a `bootstrap.ts` file in each folder found under
* the given path. If a `boostrap.ts` file is found it is imported so its content
* is executed.
*
* @param path - Path to resolve `bootstrap.ts` files.
*/
export async function bootstrap(path: string): Promise<void> {
const bootstrap: { name: string; path: string }[] = [];
for await (const entry of Deno.readDir(path)) {
if (entry.isDirectory === true) {
const moduleName = path.split("/").pop();
if (moduleName === undefined) {
continue;
}
const filePath = `${path}/${entry.name}/.tasks/bootstrap.ts`;
if (await hasFile(filePath)) {
bootstrap.push({ name: entry.name, path: filePath });
}
}
}
for (const entry of bootstrap) {
log.info(entry.name);
await import(entry.path);
}
}
async function hasFile(filePath: string) {
try {
await Deno.lstat(filePath);
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) {
throw err;
}
return false;
}
return true;
}

66
api/tasks/migrate.ts Normal file
View File

@@ -0,0 +1,66 @@
import { resolve } from "node:path";
import process from "node:process";
import { exists } from "@std/fs";
import { config } from "~libraries/database/config.ts";
import { getMongoClient } from "~libraries/database/connection.ts";
import { container } from "~libraries/database/container.ts";
import { logger } from "~libraries/logger/mod.ts";
/*
|--------------------------------------------------------------------------------
| Dependencies
|--------------------------------------------------------------------------------
*/
const client = getMongoClient(config.mongo);
container.set("client", client);
/*
|--------------------------------------------------------------------------------
| Migrate
|--------------------------------------------------------------------------------
*/
const db = client.db("api:migrations");
const collection = db.collection<MigrationDocument>("migrations");
const { default: journal } = await import(resolve(import.meta.dirname!, "migrations", "meta", "_journal.json"), {
with: { type: "json" },
});
const migrations =
(await collection.findOne({ name: journal.name })) ?? ({ name: journal.name, entries: [] } as MigrationDocument);
for (const entry of journal.entries) {
const migrationFileName = `${String(entry.idx).padStart(4, "0")}_${entry.name}.ts`;
if (migrations.entries.includes(migrationFileName)) {
continue;
}
const migrationPath = resolve(import.meta.dirname!, "migrations", migrationFileName);
if (await exists(migrationPath)) {
await import(migrationPath);
await collection.updateOne(
{
name: journal.name,
},
{
$set: { name: journal.name },
$push: { entries: migrationFileName }, // Assuming 'entries' is an array
},
{
upsert: true,
},
);
logger.info(`Migrated ${migrationPath}`);
}
}
type MigrationDocument = {
name: string;
entries: string[];
};
process.exit(0);

View File

@@ -0,0 +1,4 @@
{
"name": "api",
"entries": []
}

1
apps/README.md Normal file
View File

@@ -0,0 +1 @@
# Apps

24
apps/react/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
apps/react/.npmrc Normal file
View File

@@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io

69
apps/react/README.md Normal file
View File

@@ -0,0 +1,69 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

13
apps/react/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

34
apps/react/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "react",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "5",
"@tanstack/react-router": "1",
"@valkyr/db": "1",
"@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1",
"fast-equals": "5",
"react": "19",
"react-dom": "19"
},
"devDependencies": {
"@eslint/js": "9",
"@types/react": "19",
"@types/react-dom": "19",
"@vitejs/plugin-react": "4",
"eslint": "9",
"eslint-plugin-react-hooks": "5",
"eslint-plugin-react-refresh": "0.4",
"globals": "16",
"typescript": "5",
"typescript-eslint": "8",
"vite": "7"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
apps/react/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

36
apps/react/src/App.tsx Normal file
View File

@@ -0,0 +1,36 @@
import "./App.css";
import { useState } from "react";
import viteLogo from "/vite.svg";
import reactLogo from "./assets/react.svg";
import { Session } from "./components/Session.tsx";
function App() {
const [count, setCount] = useState(0);
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>count is {count}</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">Click on the Vite and React logos to learn more</p>
<Session />
</>
);
}
export default App;

View File

@@ -0,0 +1,267 @@
import {
assertServerErrorResponse,
type RelayAdapter,
type RelayInput,
type RelayResponse,
ServerError,
type ServerErrorResponse,
type ServerErrorType,
} from "@spec/relay";
export class HttpAdapter implements RelayAdapter {
/**
* Instantiate a new HttpAdapter instance.
*
* @param options - Adapter options.
*/
constructor(readonly options: HttpAdapterOptions) {}
/**
* Override the initial url value set by instantiator.
*/
set url(value: string) {
this.options.url = value;
}
/**
* Retrieve the URL value from options object.
*/
get url() {
return this.options.url;
}
/**
* Return the full URL from given endpoint.
*
* @param endpoint - Endpoint to get url for.
*/
getUrl(endpoint: string): string {
return `${this.url}${endpoint}`;
}
/**
* Send fetch request to the configured endpoint.
*
* @param input - Relay input parameters to use for the request.
*/
async json({ method, endpoint, query, body, headers = new Headers() }: RelayInput): Promise<RelayResponse> {
const init: RequestInit = { method, headers };
// ### Before Request
// If any before request hooks has been defined, we run them here passing in the
// request headers for further modification.
await this.#beforeRequest(headers);
// ### Content Type
// JSON requests are always of the type 'application/json' and this ensures that
// we override any custom pre-hook values for 'content-type' when executing the
// request via the 'json' method.
headers.set("content-type", "application/json");
// ### Body
if (body !== undefined) {
init.body = JSON.stringify(body);
}
// ### Response
return this.request(`${endpoint}${query}`, init);
}
async data({ method, endpoint, query, body, headers = new Headers() }: RelayInput): Promise<RelayResponse> {
const init: RequestInit = { method, headers };
// ### Before Request
// If any before request hooks has been defined, we run them here passing in the
// request headers for further modification.
await this.#beforeRequest(headers);
// ### Content Type
// For multipart uploads we let the browser set the correct boundaries.
headers.delete("content-type");
// ### Body
const formData = new FormData();
if (body !== undefined) {
for (const key in body) {
const entity = body[key];
if (entity === undefined) {
continue;
}
if (Array.isArray(entity)) {
const isFileArray = entity.length > 0 && entity.every((candidate) => candidate instanceof File);
if (isFileArray) {
for (const file of entity) {
formData.append(key, file, file.name);
}
} else {
formData.append(key, JSON.stringify(entity));
}
} else {
if (entity instanceof File) {
formData.append(key, entity, entity.name);
} else {
formData.append(key, typeof entity === "string" ? entity : JSON.stringify(entity));
}
}
}
init.body = formData;
}
// ### Response
return this.request(`${endpoint}${query}`, init);
}
/**
* Send a fetch request using the given fetch options and returns
* a relay formatted response.
*
* @param endpoint - Which endpoint to submit request to.
* @param init - Request init details to submit with the request.
*/
async request(endpoint: string, init?: RequestInit): Promise<RelayResponse> {
return this.#toResponse(await fetch(this.getUrl(endpoint), init));
}
/**
* Run before request operations.
*
* @param headers - Headers to pass to hooks.
*/
async #beforeRequest(headers: Headers) {
if (this.options.hooks?.beforeRequest !== undefined) {
for (const hook of this.options.hooks.beforeRequest) {
await hook(headers);
}
}
}
/**
* Convert a fetch response to a compliant relay response.
*
* @param response - Fetch response to convert.
*/
async #toResponse(response: Response): Promise<RelayResponse> {
const type = response.headers.get("content-type");
// ### Content Type
// Ensure that the server responds with a 'content-type' definition. We should
// always expect the server to respond with a type.
if (type === null) {
return {
result: "error",
headers: response.headers,
error: {
status: response.status,
message: "Missing 'content-type' in header returned from server.",
},
};
}
// ### Empty Response
// If the response comes back with empty response status 204 we simply return a
// empty success.
if (response.status === 204) {
return {
result: "success",
headers: response.headers,
data: null,
};
}
// ### SCIM
// If the 'content-type' is of type 'scim' we need to convert the SCIM compliant
// response to a valid relay response.
if (type === "application/scim+json") {
const parsed = await response.json();
if (response.status >= 400) {
return {
result: "error",
headers: response.headers,
error: {
status: response.status,
message: parsed.detail,
},
};
}
return {
result: "success",
headers: response.headers,
data: parsed,
};
}
// ### JSON
// If the 'content-type' contains 'json' we treat it as a 'json' compliant response
// and attempt to resolve it as such.
if (type.includes("json") === true) {
const parsed = await response.json();
if ("data" in parsed) {
return {
result: "success",
headers: response.headers,
data: parsed.data,
};
}
if ("error" in parsed) {
return {
result: "error",
headers: response.headers,
error: this.#toError(parsed),
};
}
return {
result: "error",
headers: response.headers,
error: {
status: response.status,
message: "Unsupported 'json' body returned from server, missing 'data' or 'error' key.",
},
};
}
return {
result: "error",
headers: response.headers,
error: {
status: response.status,
message: "Unsupported 'content-type' in header returned from server.",
},
};
}
#toError(candidate: unknown, status: number = 500): ServerErrorType | ServerErrorResponse["error"] {
if (assertServerErrorResponse(candidate)) {
return ServerError.fromJSON({ type: "relay", ...candidate.error });
}
if (typeof candidate === "string") {
return {
status,
message: candidate,
};
}
return {
status,
message: "Unsupported 'error' returned from server.",
};
}
}
export type HttpAdapterOptions = {
url: string;
hooks?: {
beforeRequest?: ((headers: Headers) => Promise<void>)[];
};
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,9 @@
import { makeControllerView } from "../libraries/view.ts";
import { SessionController } from "./session.controller.ts";
export const Session = makeControllerView(SessionController, ({ state: { error } }) => {
if (error !== undefined) {
return "Failed to fetch session";
}
return <div>Session OK!</div>;
});

View File

@@ -0,0 +1,24 @@
import { Controller } from "../libraries/controller.ts";
import { api } from "../services/api.ts";
export class SessionController extends Controller<{
error?: string;
}> {
async onInit() {
await this.getSessionCookie();
}
async getSessionCookie() {
const response = await api.auth.authenticate({
body: {
type: "email",
payload: {
email: "john.doe@fixture.none",
},
},
});
if ("error" in response) {
this.setState("error", undefined);
}
}
}

68
apps/react/src/index.css Normal file
View File

@@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

Some files were not shown because too many files have changed in this diff Show More