Files
tiny-forge/internal/staticsite/deno/template.go
T
alexei.dolgolyov b622384774
Build / build (push) Successful in 10m21s
feat: persistent storage for Deno static sites
- Add storage_enabled and storage_limit_mb columns to static_sites.
- Create/attach Docker volumes (tinyforge-site-{name}-data) for Deno
  sites with storage enabled, mounted at /app/data.
- Grant --allow-write=/app/data in Deno container CMD.
- Add storage usage API endpoint (GET /api/sites/{id}/storage).
- Show storage section in site detail page with usage bar.
- Add storage toggle and limit field to new site wizard.
- Use ConfirmDialog for secret deletion instead of inline delete.
2026-04-13 00:12:51 +03:00

258 lines
7.0 KiB
Go

package deno
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"text/template"
)
// RouteEntry represents a parsed API endpoint from a TypeScript file.
type RouteEntry struct {
Method string // GET, POST, PUT, DELETE, PATCH
Path string // e.g., "/api/weather/current"
ImportPath string // relative import path, e.g., "./api/weather.ts"
FunctionName string // original export name, e.g., "API_get_current"
}
// validMethods lists the HTTP methods recognized by the convention.
var validMethods = map[string]bool{
"get": true, "post": true, "put": true, "delete": true, "patch": true,
}
// apiExportPattern matches "export async function API_..." or "export function API_..."
var apiExportPattern = regexp.MustCompile(`^export\s+(?:async\s+)?function\s+(API_\w+)`)
// ScanRoutes scans all .ts files in the api/ subdirectory for API_ prefixed exports
// and returns a list of RouteEntry for each discovered endpoint.
func ScanRoutes(apiDir string) ([]RouteEntry, error) {
var routes []RouteEntry
err := filepath.Walk(apiDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext != ".ts" && ext != ".js" {
return nil
}
// Derive the base route from file path relative to apiDir.
relPath, err := filepath.Rel(apiDir, path)
if err != nil {
return fmt.Errorf("rel path: %w", err)
}
// Convert "weather.ts" → "weather", "sub/weather.ts" → "sub/weather"
baseName := strings.TrimSuffix(relPath, filepath.Ext(relPath))
baseName = filepath.ToSlash(baseName)
baseRoute := "/api/" + baseName
// Import path relative to the generated router.
importPath := "./api/" + filepath.ToSlash(relPath)
// Scan file for API_ exports.
fileRoutes, err := scanFileExports(path, baseRoute, importPath)
if err != nil {
return fmt.Errorf("scan %s: %w", relPath, err)
}
routes = append(routes, fileRoutes...)
return nil
})
return routes, err
}
// scanFileExports reads a TypeScript file and extracts API_ prefixed exports.
func scanFileExports(filePath, baseRoute, importPath string) ([]RouteEntry, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("open file: %w", err)
}
defer file.Close()
var routes []RouteEntry
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
matches := apiExportPattern.FindStringSubmatch(line)
if len(matches) < 2 {
continue
}
funcName := matches[1] // e.g., "API_get_current"
entry, ok := parseAPIFunctionName(funcName, baseRoute, importPath)
if ok {
routes = append(routes, entry)
}
}
return routes, scanner.Err()
}
// parseAPIFunctionName parses "API_get_current" into a RouteEntry.
// Convention: API_{method} → handles base route; API_{method}_{path} → handles sub-route.
func parseAPIFunctionName(funcName, baseRoute, importPath string) (RouteEntry, bool) {
// Strip "API_" prefix.
rest := strings.TrimPrefix(funcName, "API_")
if rest == "" {
return RouteEntry{}, false
}
// Split on first "_" to extract method.
parts := strings.SplitN(rest, "_", 2)
method := strings.ToLower(parts[0])
if !validMethods[method] {
return RouteEntry{}, false
}
path := baseRoute
if len(parts) == 2 && parts[1] != "" {
path = baseRoute + "/" + parts[1]
}
return RouteEntry{
Method: strings.ToUpper(method),
Path: path,
ImportPath: importPath,
FunctionName: funcName,
}, true
}
// routerTemplate is the Deno router entrypoint template.
var routerTemplate = template.Must(template.New("router").Parse(`// Auto-generated by Tinyforge — do not edit manually.
import { serveDir } from "https://deno.land/std/http/file_server.ts";
{{- range .Imports}}
import { {{.FunctionName}} as {{.Alias}} } from "{{.Path}}";
{{- end}}
const routes: Array<{ method: string; path: string; handler: (req: Request) => Promise<Response> | Response }> = [
{{- range .Routes}}
{ method: "{{.Method}}", path: "{{.Path}}", handler: {{.Alias}} },
{{- end}}
];
Deno.serve({ port: 8000 }, async (req: Request): Promise<Response> => {
const url = new URL(req.url);
const method = req.method.toUpperCase();
// Match API routes.
for (const route of routes) {
if (route.method === method && url.pathname === route.path) {
try {
return await route.handler(req);
} catch (e) {
console.error("Handler error:", e);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
}
// Serve static files from /public.
return serveDir(req, { fsRoot: "/app/public", quiet: true });
});
`))
// ImportEntry represents a single aliased import.
type ImportEntry struct {
FunctionName string // original name, e.g., "API_get"
Alias string // unique alias, e.g., "time_API_get"
Path string // import path, e.g., "./api/time.ts"
}
// routerData holds the data for the router template.
type routerData struct {
Imports []ImportEntry
Routes []routeWithAlias
}
// routeWithAlias is a route entry using the aliased handler name.
type routeWithAlias struct {
Method string
Path string
Alias string
}
// GenerateRouter generates the Deno router TypeScript source from route entries.
func GenerateRouter(routes []RouteEntry) (string, error) {
var imports []ImportEntry
var aliasedRoutes []routeWithAlias
for _, r := range routes {
// Derive a unique alias from the file base name + function name.
// e.g., "./api/echo.ts" + "API_get" → "echo_API_get"
baseName := filepath.Base(r.ImportPath)
baseName = strings.TrimSuffix(baseName, filepath.Ext(baseName))
alias := baseName + "_" + r.FunctionName
imports = append(imports, ImportEntry{
FunctionName: r.FunctionName,
Alias: alias,
Path: r.ImportPath,
})
aliasedRoutes = append(aliasedRoutes, routeWithAlias{
Method: r.Method,
Path: r.Path,
Alias: alias,
})
}
data := routerData{Imports: imports, Routes: aliasedRoutes}
var buf strings.Builder
if err := routerTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("execute router template: %w", err)
}
return buf.String(), nil
}
// GenerateDockerfile generates the Dockerfile for the Deno container.
func GenerateDockerfile() string {
return `FROM denoland/deno:latest
WORKDIR /app
# Copy static files.
COPY public/ /app/public/
# Copy API source files and generated router.
COPY api/ /app/api/
COPY router.ts /app/router.ts
# Create data directory for persistent storage (mounted as volume when enabled).
RUN mkdir -p /app/data
# Cache dependencies.
RUN deno install --entrypoint router.ts
EXPOSE 8000
CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-write=/app/data", "--allow-env", "router.ts"]
`
}
// GenerateStaticDockerfile generates a minimal nginx Dockerfile for pure static sites.
func GenerateStaticDockerfile() string {
return `FROM nginx:alpine
COPY . /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
`
}