Skip to content

Commit 847f807

Browse files
feat: htmx test app (#383)
* feat: app-htmx * chore: respond with from-to in extract api * add environment variable APP_HTMX_PORT --------- Co-authored-by: Ivan Ivic <ivanivic842@gmail.com>
1 parent 6bb6fd0 commit 847f807

30 files changed

+1395
-4
lines changed

apps/app-htmx/build.mjs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import * as esbuild from "esbuild";
2+
3+
await esbuild.build({
4+
keepNames: true,
5+
bundle: true,
6+
platform: "node",
7+
format: "esm",
8+
target: "esnext",
9+
metafile: true,
10+
outdir: "out",
11+
entryPoints: ["src/index.ts"],
12+
mainFields: ["module", "main"],
13+
external: [
14+
'@clerk/fastify',
15+
'@fastify/formbody',
16+
'@fastify/view',
17+
'fastify',
18+
'nunjucks',
19+
],
20+
banner: {
21+
js: [
22+
`import { createRequire as topLevelCreateRequire } from 'module';`,
23+
`const require = topLevelCreateRequire(import.meta.url);`,
24+
`import { fileURLToPath as topLevelFileUrlToPath, URL as topLevelURL } from "url"`,
25+
`const __dirname = topLevelFileUrlToPath(new topLevelURL(".", import.meta.url))`,
26+
].join("\n"),
27+
},
28+
29+
});

apps/app-htmx/package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@acme/app-htmx",
3+
"version": "0.1.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"start": "esbuild src/index.ts --bundle --platform=node | npm run with-env node",
8+
"dev": "node build.mjs && npm run with-env node ./out/index.js",
9+
"with-env": "dotenv -e ../../.env --"
10+
},
11+
"devDependencies": {
12+
"@types/nunjucks": "3.2.6",
13+
"dotenv-cli": "^7.3.0",
14+
"esbuild": "^0.19.9"
15+
},
16+
"dependencies": {
17+
"@acme/source-control": "*",
18+
"@acme/super-schema": "*",
19+
"@clerk/fastify": "0.6.30",
20+
"@fastify/formbody": "7.4.0",
21+
"@fastify/view": "8.2.0",
22+
"date-fns": "3.3.1",
23+
"fastify": "4.25.2",
24+
"nunjucks": "3.2.4"
25+
}
26+
}

apps/app-htmx/src/app-config.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { z } from "zod"
2+
3+
const ENVSchema = z.object({
4+
SUPER_DATABASE_URL: z.string(),
5+
SUPER_DATABASE_AUTH_TOKEN: z.string().optional(),
6+
TENANT_DATABASE_AUTH_TOKEN: z.string().optional(),
7+
8+
// TODO: remove after removing next
9+
NEXT_PUBLIC_EXTRACT_API_URL: z.string(),
10+
NEXT_PUBLIC_TRANSFORM_API_URL: z.string(),
11+
12+
CLERK_PUBLISHABLE_KEY: z.string(),
13+
CLERK_SECRET_KEY: z.string(),
14+
CLERK_DOMAIN: z.string(),
15+
APP_HTMX_PORT: z.coerce.number().optional(),
16+
});
17+
18+
const parsedEnv = ENVSchema.safeParse(process.env);
19+
if (!parsedEnv.success) throw new Error(`Invalid environment: ${parsedEnv.error}`);
20+
21+
export const AppConfig = {
22+
port: parsedEnv.data.APP_HTMX_PORT,
23+
superDatabase: {
24+
url: parsedEnv.data.SUPER_DATABASE_URL,
25+
authToken: parsedEnv.data.SUPER_DATABASE_AUTH_TOKEN,
26+
},
27+
tenantDatabaseAuthToken: parsedEnv.data.TENANT_DATABASE_AUTH_TOKEN,
28+
apis: {
29+
extractStart: parsedEnv.data.NEXT_PUBLIC_EXTRACT_API_URL,
30+
transformStart: parsedEnv.data.NEXT_PUBLIC_TRANSFORM_API_URL,
31+
},
32+
clerk: {
33+
domain: parsedEnv.data.CLERK_DOMAIN,
34+
publishableKey: parsedEnv.data.CLERK_PUBLISHABLE_KEY,
35+
secretKey: "", // should be left in ENV
36+
}
37+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { IncomingHttpHeaders } from "http";
2+
3+
export const htmxContext = (headers: IncomingHttpHeaders) => ({
4+
htmx: {
5+
boosted: 'hx-boosted' in headers,
6+
}
7+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const pageContext = (title: string) => ({
2+
page: {
3+
title
4+
},
5+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { getTenants } from "@acme/super-schema";
2+
import { createClient } from "@libsql/client";
3+
import { drizzle } from "drizzle-orm/libsql";
4+
import { AppConfig } from "src/app-config";
5+
6+
const loadTenants = async ()=> {
7+
const superDb = drizzle(createClient(AppConfig.superDatabase));
8+
9+
const tenants = await getTenants(superDb);
10+
11+
return tenants;
12+
}
13+
const TENANT_LIST = await loadTenants();
14+
15+
export const tenantListContext = () => ({
16+
tenantList: TENANT_LIST
17+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { GitHubSourceControl, GitlabSourceControl, type SourceControl } from "@acme/source-control";
2+
import { clerkClient } from "@clerk/fastify";
3+
4+
const getUserForgeAccessToken = async (userId: string, forge: "github" | "gitlab") => {
5+
const userTokens = await clerkClient.users.getUserOauthAccessToken(userId, `oauth_${forge}`);
6+
if (userTokens[0] === undefined) throw new Error("no token");
7+
return userTokens[0].token;
8+
}
9+
10+
type Props = {
11+
forge: "github" | "gitlab";
12+
userId: string;
13+
repositoryId: number;
14+
repositoryName: string;
15+
namespaceName: string;
16+
}
17+
export const tryFetchRepository = async ({
18+
userId,
19+
forge,
20+
namespaceName,
21+
repositoryId,
22+
repositoryName,
23+
}: Props) => {
24+
const token = await getUserForgeAccessToken(userId, forge);
25+
26+
let sc: SourceControl;
27+
if (forge === 'github') sc = new GitHubSourceControl(token);
28+
else sc = new GitlabSourceControl(token);
29+
30+
try {
31+
const { repository, namespace } = await sc.fetchRepository(repositoryId, namespaceName, repositoryName);
32+
return { repository, namespace };
33+
} catch (error) {
34+
console.log("FAILED TO FETCH REPOSITORY");
35+
console.log(error);
36+
}
37+
38+
return {
39+
repository: undefined,
40+
namespace: undefined
41+
}
42+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { Tenant } from "@acme/super-schema";
2+
import { type Client, createClient } from "@libsql/client";
3+
import { fromUnixTime } from "date-fns/fromUnixTime";
4+
import { formatDistanceToNow } from "date-fns/formatDistanceToNow";
5+
import { AppConfig } from "src/app-config";
6+
import { z } from "zod";
7+
8+
const formatDuration = (s: number) => {
9+
const seconds = s % 60;
10+
if (s < 60) return `${seconds}s`
11+
const m = (s - seconds) / 60;
12+
const minutes = m % 60;
13+
if (m < 60) return `${minutes}m ${seconds.toString().padStart(2,'0')}s`
14+
const h = (m - minutes) / 60;
15+
return `${h}h ${minutes.toString().padStart(2, '0')}m ${seconds.toString().padStart(2, '0')}s`
16+
}
17+
18+
const rowSchema = z.object({
19+
crawl_instance: z.number(),
20+
event_count: z.number(),
21+
crawl_duration_sec: z.number(),
22+
crawl_started_at: z.number()
23+
});
24+
25+
const readCrawlStats =async (client:Client) => {
26+
const x = await client.execute(`
27+
SELECT
28+
ce.instance_id as crawl_instance,
29+
COUNT(ce.instance_id) as event_count,
30+
MAX(ce.timestamp) - ci.started_at AS crawl_duration_sec,
31+
ci.started_at as crawl_started_at
32+
FROM crawl_events ce
33+
JOIN crawl_instances ci ON ce.instance_id = ci.id
34+
GROUP BY ce.instance_id;
35+
`);
36+
const rawStats = x.rows.map(row => rowSchema.parse(row));
37+
38+
return rawStats.map((rawStat)=>({
39+
instanceId: rawStat.crawl_instance,
40+
eventCount: rawStat.event_count,
41+
duration: formatDuration(rawStat.crawl_duration_sec),
42+
startedAt: formatDistanceToNow(fromUnixTime(rawStat.crawl_started_at))
43+
}))
44+
}
45+
46+
export const getCrawlStats = async (tenant: Tenant) => {
47+
const client = createClient({
48+
url: tenant.dbUrl,
49+
authToken: AppConfig.tenantDatabaseAuthToken
50+
});
51+
52+
return await readCrawlStats(client);
53+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { namespaces, repositories } from "@acme/extract-schema";
2+
import { eq } from "drizzle-orm";
3+
import type { LibSQLDatabase } from "drizzle-orm/libsql";
4+
5+
export const getRepositories = async (db: LibSQLDatabase) => {
6+
const repos = await db.select({
7+
forge: repositories.forgeType,
8+
name: repositories.name,
9+
org: namespaces.name,
10+
projectId: repositories.externalId,
11+
}).from(repositories).innerJoin(namespaces, eq(repositories.namespaceId, namespaces.id)).all()
12+
13+
return repos;
14+
}

apps/app-htmx/src/index.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import Fastify from "fastify";
2+
import nunjucks from "nunjucks";
3+
import { fastifyView } from "@fastify/view";
4+
import path from "path";
5+
import { fileURLToPath } from "url";
6+
import { clerkPlugin } from "@clerk/fastify";
7+
import { Home } from "./pages/page.home.js";
8+
import fastifyFormbody from "@fastify/formbody";
9+
import { SignIn } from "./pages/page.sign-in.js";
10+
import { ExtractRepository } from "./pages/repository/extract.js";
11+
import { RegisterRepository } from "./pages/repository/register.js";
12+
import { AppConfig } from "./app-config.js";
13+
import { StartTransform } from "./pages/start-transform.js";
14+
15+
AppConfig; // ensure loaded before starting server
16+
17+
const TEMPLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "../", "src", "views");
18+
19+
const fastify = Fastify({ logger: true });
20+
21+
22+
await fastify.register(fastifyView, {
23+
engine: {
24+
nunjucks: nunjucks,
25+
},
26+
templates: TEMPLATE_DIR
27+
});
28+
await fastify.register(clerkPlugin);
29+
await fastify.register(fastifyFormbody);
30+
31+
fastify.get('/', Home);
32+
fastify.get('/sign-in',SignIn);
33+
fastify.post('/repository/extract', ExtractRepository);
34+
fastify.post('/repository/register', RegisterRepository);
35+
fastify.post('/transform', StartTransform);
36+
37+
const PORT = AppConfig.port || 3001;
38+
39+
await fastify.listen({ port: PORT, host: "127.0.0.1" })
40+
console.log(`Server started on http://127.0.0.1:${PORT}/`);

0 commit comments

Comments
 (0)