b622384774
Build / build (push) Successful in 10m21s
- 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.
258 lines
7.0 KiB
Go
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;"]
|
|
`
|
|
}
|