Add microapp platform scaffold

This commit is contained in:
Ruslan Bakiev
2026-03-23 11:47:14 +07:00
parent 3d7e963087
commit d4fc4f66cc
11 changed files with 544 additions and 1 deletions

View File

@@ -0,0 +1,22 @@
FROM debian:bookworm-slim
ARG DENO_VERSION=2.6.4
RUN apt-get update \
&& apt-get install --yes --no-install-recommends ca-certificates curl tini unzip \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "https://dl.deno.land/release/v${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \
&& unzip /tmp/deno.zip -d /usr/local/bin \
&& chmod +x /usr/local/bin/deno \
&& rm /tmp/deno.zip
WORKDIR /workspace
COPY microapps/runtime/entrypoint.sh /usr/local/bin/microapp-entrypoint
RUN chmod +x /usr/local/bin/microapp-entrypoint
ENV DENO_DIR=/deno-dir
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/microapp-entrypoint"]

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env sh
set -eu
APP_DIR="${APP_DIR:-/workspace/app}"
ENTRYPOINT_FILE="${ENTRYPOINT_FILE:-$APP_DIR/main.ts}"
ALLOW_READ="${ALLOW_READ:-$APP_DIR}"
ALLOW_ENV="${ALLOW_ENV:-PORT,POCKETBASE_URL,POCKETBASE_SERVICE_EMAIL,POCKETBASE_SERVICE_PASSWORD,APP_OWNER_TEAM_ID,APP_SLUG}"
ALLOW_NET="${ALLOW_NET:-127.0.0.1,localhost}"
exec deno run \
--no-prompt \
--allow-read="$ALLOW_READ" \
--allow-env="$ALLOW_ENV" \
--allow-net="$ALLOW_NET" \
"$ENTRYPOINT_FILE"

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env sh
set -eu
IMAGE_NAME="${IMAGE_NAME:-clientflow/microapp-runtime}"
APP_DIR="${APP_DIR:-$(pwd)/microapps/templates/pocketbase-deno-dashboard}"
ALLOW_NET="${ALLOW_NET:-127.0.0.1,localhost}"
docker run \
--rm \
--runtime=runsc \
--memory=256m \
--cpus=0.5 \
--pids-limit=128 \
-e APP_DIR=/workspace/app \
-e ALLOW_NET="$ALLOW_NET" \
-v "$APP_DIR:/workspace/app:ro" \
"$IMAGE_NAME"

View File

@@ -0,0 +1,28 @@
export default {
manifestVersion: 1,
name: "PocketBase Deno Dashboard",
slug: "pocketbase-deno-dashboard",
runtime: "deno-gvisor",
entrypoint: "./main.ts",
pocketbase: {
collections: ["dashboard_cards"],
collectionPrefix: "dashboard",
},
permissions: {
allowEnv: [
"PORT",
"POCKETBASE_URL",
"POCKETBASE_SERVICE_EMAIL",
"POCKETBASE_SERVICE_PASSWORD",
"APP_OWNER_TEAM_ID",
"APP_SLUG",
],
allowNet: ["pocketbase.internal"],
allowRead: ["./"],
allowWrite: [],
},
libraries: [
"internal/pocketbase-client",
"internal/dashboard-ui",
],
} as const;

View File

@@ -0,0 +1,17 @@
{
"tasks": {
"dev": "deno run --watch --allow-read=. --allow-env=PORT,POCKETBASE_URL,POCKETBASE_SERVICE_EMAIL,POCKETBASE_SERVICE_PASSWORD,APP_OWNER_TEAM_ID,APP_SLUG --allow-net=127.0.0.1,localhost,pocketbase.internal main.ts",
"check": "deno check main.ts pocketbase/client.ts app.config.ts"
},
"fmt": {
"lineWidth": 100,
"singleQuote": false
},
"lint": {
"rules": {
"tags": [
"recommended"
]
}
}
}

View File

@@ -0,0 +1,162 @@
import appConfig from "./app.config.ts";
import { createPocketBaseClient } from "./pocketbase/client.ts";
const port = Number(Deno.env.get("PORT") || "8080");
const pocketbaseUrl = mustGetEnv("POCKETBASE_URL");
const pocketbaseEmail = mustGetEnv("POCKETBASE_SERVICE_EMAIL");
const pocketbasePassword = mustGetEnv("POCKETBASE_SERVICE_PASSWORD");
const ownerTeamId = mustGetEnv("APP_OWNER_TEAM_ID");
const appSlug = Deno.env.get("APP_SLUG") || appConfig.slug;
const pocketbase = createPocketBaseClient({
baseUrl: pocketbaseUrl,
email: pocketbaseEmail,
password: pocketbasePassword,
});
type DashboardCard = {
id: string;
title: string;
metric: string;
description: string | null;
};
function renderHtml(cards: DashboardCard[]) {
const cardsMarkup = cards.length === 0
? `<p class="empty">No dashboard cards yet.</p>`
: cards.map((card) => `
<article class="card">
<p class="label">${escapeHtml(card.title)}</p>
<strong>${escapeHtml(card.metric)}</strong>
<span>${escapeHtml(card.description || "No description")}</span>
</article>
`).join("");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(appConfig.name)}</title>
<style>
:root {
color-scheme: light;
--bg: #f4efe5;
--panel: #fffaf2;
--ink: #17231f;
--muted: #52615a;
--accent: #0d8a62;
--border: #d7cbb8;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background:
radial-gradient(circle at top left, rgba(13, 138, 98, 0.2), transparent 32%),
linear-gradient(180deg, #f8f3ea 0%, var(--bg) 100%);
color: var(--ink);
}
main {
max-width: 960px;
margin: 0 auto;
padding: 48px 20px 80px;
}
.hero {
padding: 28px;
border: 1px solid var(--border);
border-radius: 28px;
background: rgba(255, 250, 242, 0.92);
backdrop-filter: blur(10px);
}
.eyebrow {
margin: 0 0 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 12px;
color: var(--accent);
}
h1 {
margin: 0;
font-size: clamp(32px, 5vw, 56px);
line-height: 0.96;
}
.meta {
margin-top: 18px;
color: var(--muted);
font-size: 15px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 24px;
}
.card {
border-radius: 20px;
padding: 20px;
border: 1px solid rgba(23, 35, 31, 0.08);
background: var(--panel);
box-shadow: 0 10px 24px rgba(23, 35, 31, 0.08);
}
.label {
margin: 0 0 12px;
color: var(--muted);
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
strong {
display: block;
font-size: 34px;
margin-bottom: 8px;
}
span, .empty {
color: var(--muted);
}
</style>
</head>
<body>
<main>
<section class="hero">
<p class="eyebrow">PocketBase + Deno</p>
<h1>${escapeHtml(appConfig.name)}</h1>
<p class="meta">App slug: ${escapeHtml(appSlug)} • Team: ${escapeHtml(ownerTeamId)}</p>
</section>
<section class="grid">${cardsMarkup}</section>
</main>
</body>
</html>`;
}
function mustGetEnv(name: string) {
const value = Deno.env.get(name);
if (!value) {
throw new Error(`Missing required env: ${name}`);
}
return value;
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
async function handleRequest() {
const cards = await pocketbase.listRecords<DashboardCard>({
collectionName: `${appConfig.pocketbase.collectionPrefix}_cards`,
filter: `teamId = "${ownerTeamId}"`,
});
return new Response(renderHtml(cards), {
headers: {
"content-type": "text/html; charset=utf-8",
},
});
}
Deno.serve({ port }, () => handleRequest());

View File

@@ -0,0 +1,73 @@
type PocketBaseClientOptions = {
baseUrl: string;
email: string;
password: string;
};
type PocketBaseListOptions = {
collectionName: string;
filter?: string;
};
type AuthResponse = {
token: string;
};
type RecordListResponse<TRecord> = {
items: TRecord[];
};
export function createPocketBaseClient(options: PocketBaseClientOptions) {
let cachedToken: string | null = null;
async function authenticate() {
if (cachedToken) return cachedToken;
const response = await fetch(
`${options.baseUrl}/api/collections/_superusers/auth-with-password`,
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
identity: options.email,
password: options.password,
}),
},
);
if (!response.ok) {
throw new Error(`PocketBase auth failed with status ${response.status}`);
}
const payload = await response.json() as AuthResponse;
cachedToken = payload.token;
return cachedToken;
}
return {
async listRecords<TRecord>(input: PocketBaseListOptions) {
const token = await authenticate();
const url = new URL(
`${options.baseUrl}/api/collections/${input.collectionName}/records`,
);
if (input.filter) {
url.searchParams.set("filter", input.filter);
}
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`PocketBase list failed with status ${response.status}`);
}
const payload = await response.json() as RecordListResponse<TRecord>;
return payload.items;
},
};
}

View File

@@ -0,0 +1,69 @@
const projectDir = Deno.args[0];
if (!projectDir) {
throw new Error("Usage: deno run --allow-read microapps/validator/validate_microapp_repo.ts <project-dir>");
}
const requiredFiles = new Set([
"app.config.ts",
"deno.json",
"main.ts",
"pocketbase/client.ts",
]);
const allowedTopLevelEntries = new Set([
"app.config.ts",
"deno.json",
"main.ts",
"pocketbase",
]);
const rootEntries = new Set<string>();
for await (const entry of Deno.readDir(projectDir)) {
rootEntries.add(entry.name);
}
for (const file of requiredFiles) {
const path = `${projectDir}/${file}`;
await Deno.stat(path);
}
for (const entry of rootEntries) {
if (!allowedTopLevelEntries.has(entry)) {
throw new Error(`Unexpected top-level entry: ${entry}`);
}
}
const configModule = await import(`file://${Deno.realPathSync(`${projectDir}/app.config.ts`)}`);
const config = configModule.default as {
manifestVersion?: number;
runtime?: string;
libraries?: string[];
permissions?: {
allowEnv?: string[];
allowNet?: string[];
};
};
if (config.manifestVersion !== 1) {
throw new Error("app.config.ts must export manifestVersion = 1");
}
if (config.runtime !== "deno-gvisor") {
throw new Error("app.config.ts must declare runtime = 'deno-gvisor'");
}
if (!Array.isArray(config.libraries) || config.libraries.length === 0) {
throw new Error("app.config.ts must declare at least one approved library");
}
if (!Array.isArray(config.permissions?.allowEnv) || config.permissions.allowEnv.length === 0) {
throw new Error("app.config.ts must declare allowEnv permissions");
}
if (!Array.isArray(config.permissions?.allowNet) || config.permissions.allowNet.length === 0) {
throw new Error("app.config.ts must declare allowNet permissions");
}
console.log(`Microapp repo at ${projectDir} matches the enforced template contract.`);