diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..94338931 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Shell scripts must keep LF: a CRLF shebang makes the kernel look for +# "/bin/sh " and the container dies with "exec ... No such file or directory". +*.sh text eol=lf +Dockerfile text eol=lf +Caddyfile* text eol=lf +entrypoint.sh text eol=lf diff --git a/Caddyfile.local b/Caddyfile.local index f0f23035..9900bd7a 100644 --- a/Caddyfile.local +++ b/Caddyfile.local @@ -1,10 +1,13 @@ http://localhost:8080 { - @admin_ui { - path /_/admin/ui/* - path /_/admin/ui + # vite's base is /_/admin/ui/ and it won't redirect the bare path itself + @admin_ui_bare path /_/admin/ui + handle @admin_ui_bare { + redir * /_/admin/ui/ 308 } + @admin_ui path /_/admin/ui/* + handle @admin_ui { reverse_proxy host.docker.internal:3000 } diff --git a/Readme.md b/Readme.md index 58b36b53..558b6eb8 100644 --- a/Readme.md +++ b/Readme.md @@ -24,7 +24,7 @@
- + Fluxify Platform Preview diff --git a/apps/ai-gateway/src/constants.ts b/apps/ai-gateway/src/constants.ts index 4bb8cb9e..5f6b4081 100644 --- a/apps/ai-gateway/src/constants.ts +++ b/apps/ai-gateway/src/constants.ts @@ -1,7 +1,11 @@ import path from "path"; import { DOCS_INDEX_FILE_PATH } from "./lib/env"; -export const DOCS_INDEX_PATH = path.join( +// resolve, not join: DOCS_INDEX_FILE_PATH is absolute in container images, and +// path.join would append it to dirname instead of honouring it +// (/app/ai-gateway + /app/ai-gateway/docs-index.bin). Relative values still +// resolve against this file's directory, which is what the default expects. +export const DOCS_INDEX_PATH = path.resolve( import.meta.dirname, DOCS_INDEX_FILE_PATH, ); diff --git a/apps/portal/package.json b/apps/portal/package.json index 0a3c2e7a..e720e5ca 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 3001", + "dev": "vite --port 3000", "build": "vite build", "preview": "vite preview", "lint": "tsgo --noEmit", diff --git a/apps/portal/src/components/ai/AiHome.tsx b/apps/portal/src/components/ai/AiHome.tsx index da866dcb..c23214a4 100644 --- a/apps/portal/src/components/ai/AiHome.tsx +++ b/apps/portal/src/components/ai/AiHome.tsx @@ -10,7 +10,7 @@ import { PromptEditor } from "./PromptEditor"; import { STARTERS } from "./starters"; import { useAiModels } from "./useAiModels"; -const logo = `/_/admin/ui/public/icons/logo.svg`; +const logo = `${import.meta.env.BASE_URL}icons/logo.webp`; export function AiHome() { const { projectId } = useParams({ from: "/_authed/$projectId/ai/" }); diff --git a/apps/portal/src/components/ai/ResourceChip.tsx b/apps/portal/src/components/ai/ResourceChip.tsx index 137c67b7..292f5b5a 100644 --- a/apps/portal/src/components/ai/ResourceChip.tsx +++ b/apps/portal/src/components/ai/ResourceChip.tsx @@ -57,7 +57,7 @@ export function ResourceChip({ type, identifier, name, data }: ResourceChipProps let targetUrl = ""; if (projectId) { if (type === "route") { - targetUrl = `/${projectId}/editor/${identifier}`; + targetUrl = `/${projectId}/canvas/${identifier}`; } else if (type === "integration") { targetUrl = `/${projectId}/integrations?group=ai&open=${identifier}`; } else if (type === "app_config") { diff --git a/apps/portal/src/components/ai/artifacts/ApplyBar.tsx b/apps/portal/src/components/ai/artifacts/ApplyBar.tsx index f460ecd1..20f604eb 100644 --- a/apps/portal/src/components/ai/artifacts/ApplyBar.tsx +++ b/apps/portal/src/components/ai/artifacts/ApplyBar.tsx @@ -11,11 +11,9 @@ import { useArtifactParams } from "./useArtifact"; function Applied({ appliedAt, routeId, - target, }: { appliedAt: string | Date; routeId?: string; - target: "editor" | "canvas"; }) { const { projectId } = useArtifactParams(); return ( @@ -23,11 +21,7 @@ function Applied({ {routeId && ( - -
- - - - -
-
- {groups.map((g) => ( -
-
- {g.group} -
- {g.blocks.map((b) => ( - - ))} -
- ))} - {groups.length === 0 && ( -

No blocks match.

- )} -
- - ); -} diff --git a/apps/portal/src/components/editor/GenericBlockNode.tsx b/apps/portal/src/components/editor/GenericBlockNode.tsx deleted file mode 100644 index ef140d68..00000000 --- a/apps/portal/src/components/editor/GenericBlockNode.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Handle, Position } from "@xyflow/react"; - -export type GenericNodeData = { - label?: string; - name?: string; - targets?: string[]; - sources?: string[]; -}; - -// ponytail: Stage 2 node — generic visual, but renders the exact handle IDs its -// edges reference (derived in the editor route) so connections draw correctly -// for any block type. Stage 3 swaps in per-type icons/UIs. -export function GenericBlockNode({ - data, - type, - selected, -}: { - data?: GenericNodeData; - type?: string; - selected?: boolean; -}) { - const targets = data?.targets ?? []; - const sources = data?.sources ?? []; - - return ( -
- {targets.length === 0 ? ( - - ) : ( - targets.map((id, i) => ( - - )) - )} - -
- {type} -
-
- {data?.label ?? data?.name ?? type ?? "Block"} -
- - {sources.length === 0 ? ( - - ) : ( - sources.map((id, i) => ( - - )) - )} -
- ); -} diff --git a/apps/portal/src/components/editor/blockCatalog.ts b/apps/portal/src/components/editor/blockCatalog.ts deleted file mode 100644 index 2a203051..00000000 --- a/apps/portal/src/components/editor/blockCatalog.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { BlockTypes } from "@/types/block"; - -export type CatalogEntry = { type: string; label: string }; -export type CatalogGroup = { group: string; blocks: CatalogEntry[] }; - -// Addable blocks grouped by category (mirrors the web block palette). -export const BLOCK_CATALOG: CatalogGroup[] = [ - { - group: "Core", - blocks: [ - { type: BlockTypes.response, label: "Response" }, - { type: BlockTypes.errorHandler, label: "Error Handler" }, - { type: BlockTypes.setvar, label: "Set Variable" }, - { type: BlockTypes.getvar, label: "Get Variable" }, - { type: BlockTypes.transformer, label: "Transformer" }, - { type: BlockTypes.jsrunner, label: "JS Runner" }, - { type: BlockTypes.arrayops, label: "Array Operations" }, - ], - }, - { - group: "Flow", - blocks: [ - { type: BlockTypes.if, label: "If" }, - { type: BlockTypes.forloop, label: "For Loop" }, - { type: BlockTypes.foreachloop, label: "Foreach Loop" }, - ], - }, - { - group: "Database", - blocks: [ - { type: BlockTypes.db_getsingle, label: "Get Single Record" }, - { type: BlockTypes.db_getall, label: "Get All Records" }, - { type: BlockTypes.db_insert, label: "Insert New Record" }, - { type: BlockTypes.db_insertbulk, label: "Insert Bulk Record" }, - { type: BlockTypes.db_update, label: "Update Record(s)" }, - { type: BlockTypes.db_delete, label: "Delete Record(s)" }, - { type: BlockTypes.db_transaction, label: "Database Transaction" }, - { type: BlockTypes.db_native, label: "Native Database" }, - ], - }, - { - group: "HTTP", - blocks: [ - { type: BlockTypes.httprequest, label: "Http Request" }, - { type: BlockTypes.httpgetcookie, label: "Get Cookie" }, - { type: BlockTypes.httpsetcookie, label: "Set Cookie" }, - { type: BlockTypes.httpgetheader, label: "Get Header" }, - { type: BlockTypes.httpsetheader, label: "Set Header" }, - { type: BlockTypes.httpgetparam, label: "Get Param" }, - { type: BlockTypes.httpgetrequestbody, label: "Get Request Body" }, - ], - }, - { - group: "Logging", - blocks: [ - { type: BlockTypes.consolelog, label: "Console" }, - { type: BlockTypes.cloudLogs, label: "Cloud Log store" }, - ], - }, -]; diff --git a/apps/portal/src/components/editor/blocks/BaseBlock.tsx b/apps/portal/src/components/editor/blocks/BaseBlock.tsx deleted file mode 100644 index 8bf1a43d..00000000 --- a/apps/portal/src/components/editor/blocks/BaseBlock.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { ReactNode } from "react"; -import { cn } from "@fluxify/components"; - -type Props = { - blockId: string; - blockName: string; - icon?: ReactNode; - children?: ReactNode; // handles - selected?: boolean; - labelPlacement?: "top" | "bottom" | "left" | "right"; - topLeftRounded?: boolean; - topRightRounded?: boolean; - bottomLeftRounded?: boolean; - bottomRightRounded?: boolean; - color?: string; -}; - -// HeroUI/Tailwind port of the web BaseBlock: compact icon card with a label and -// per-corner rounding (entrypoint = rounded top, response = rounded bottom). -export function BaseBlock({ - blockName, - icon, - children, - selected, - labelPlacement = "top", - topLeftRounded, - topRightRounded, - bottomLeftRounded, - bottomRightRounded, - color, -}: Props) { - return ( -
- {icon} - {children} - - {blockName} - -
- ); -} diff --git a/apps/portal/src/components/editor/blocks/BlockHandle.tsx b/apps/portal/src/components/editor/blocks/BlockHandle.tsx deleted file mode 100644 index 66379d43..00000000 --- a/apps/portal/src/components/editor/blocks/BlockHandle.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Handle, Position, useNodeConnections } from "@xyflow/react"; -import type React from "react"; - -type Props = { - style?: React.CSSProperties; - position?: Position; - blockId: string; - type: "source" | "target"; - handleVariant?: string; - color?: string; -}; - -// Ported verbatim from web: handle id is `${blockId}-${variant ?? type}`, which -// is exactly what the saved edges reference. -export function BlockHandle(props: Props) { - const handleId = `${props.blockId}-${props.handleVariant ?? props.type}`; - const isVertical = - props.position === Position.Top || props.position === Position.Bottom; - const isTarget = props.type === "target"; - const width = isVertical ? "15px" : "6px"; - const height = isVertical ? "6px" : "15px"; - const connection = useNodeConnections({ handleId, handleType: props.type }); - - return ( - - ); -} diff --git a/apps/portal/src/components/editor/blocks/nodes.tsx b/apps/portal/src/components/editor/blocks/nodes.tsx deleted file mode 100644 index 749ae075..00000000 --- a/apps/portal/src/components/editor/blocks/nodes.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import type { ReactNode } from "react"; -import { type NodeProps, Position } from "@xyflow/react"; -import { TbWorldCode, TbDoorExit, TbInfinity, TbTransform, TbCodeVariablePlus, TbMatrix, TbCookie, TbDatabaseX, TbDatabaseSearch, TbDatabasePlus, TbDatabaseEdit, TbDatabaseImport, TbTerminal2, TbCloud } from "react-icons/tb"; -import { MdOutlineReportGmailerrorred, MdHttp, MdDataObject } from "react-icons/md"; -import { FaMapSigns, FaHeading } from "react-icons/fa"; -import { IoLogoJavascript } from "react-icons/io"; -import { VscSymbolParameter } from "react-icons/vsc"; -import { LuDatabaseZap } from "react-icons/lu"; -import { BlockTypes } from "@/types/block"; -import { BaseBlock } from "./BaseBlock"; -import { BlockHandle } from "./BlockHandle"; - -const GREEN = "#40c057"; -const VIOLET = "#7c5cff"; -const RED = "#e5484d"; - -type Label = "top" | "bottom" | "left" | "right"; -type HandleCfg = { - type: "source" | "target"; - position: Position; - variant?: string; - color?: string; -}; -type Shape = { - topLeftRounded?: boolean; - topRightRounded?: boolean; - bottomLeftRounded?: boolean; - bottomRightRounded?: boolean; -}; - -// Standard block: one target on top, one source on bottom. -const STD: HandleCfg[] = [ - { type: "target", position: Position.Top }, - { type: "source", position: Position.Bottom }, -]; - -function node( - name: string, - icon: ReactNode, - label: Label, - handles: HandleCfg[] = STD, - shape: Shape = {}, -) { - return function Node(props: NodeProps) { - return ( - - {handles.map((h) => ( - - ))} - - ); - }; -} - -const sz = 18; - -export const blocksList: Record ReactNode> = { - [BlockTypes.entrypoint]: node("Entrypoint", , "top", [{ type: "source", position: Position.Bottom }], { topLeftRounded: true, topRightRounded: true }), - [BlockTypes.response]: node("Response", , "bottom", [{ type: "target", position: Position.Top }], { bottomLeftRounded: true, bottomRightRounded: true }), - [BlockTypes.errorHandler]: node("Error Handler", , "top", [{ type: "source", position: Position.Bottom, color: RED }]), - [BlockTypes.if]: node("If", , "bottom", [ - { type: "target", position: Position.Top }, - { type: "source", position: Position.Left, variant: "success", color: GREEN }, - { type: "source", position: Position.Right, variant: "failure", color: RED }, - ]), - [BlockTypes.forloop]: node("For", , "left", [ - { type: "target", position: Position.Top }, - { type: "source", position: Position.Bottom }, - { type: "source", position: Position.Right, variant: "executor", color: GREEN }, - ]), - [BlockTypes.foreachloop]: node("Foreach", , "left", [ - { type: "target", position: Position.Top }, - { type: "source", position: Position.Bottom }, - { type: "source", position: Position.Right, variant: "executor", color: VIOLET }, - ]), - [BlockTypes.transformer]: node("Transformer", , "left"), - [BlockTypes.jsrunner]: node("JS Runner", , "left"), - [BlockTypes.setvar]: node("Set Variable", , "left"), - [BlockTypes.getvar]: node("Get Variable", , "left"), - [BlockTypes.arrayops]: node("Array Operations", , "left"), - [BlockTypes.httprequest]: node("Http Request", , "left"), - [BlockTypes.httpgetcookie]: node("Get Cookie", , "left"), - [BlockTypes.httpsetcookie]: node("Set Cookie", , "left"), - [BlockTypes.httpgetheader]: node("Get Header", , "left"), - [BlockTypes.httpsetheader]: node("Set Header", , "left"), - [BlockTypes.httpgetparam]: node("Get Param", , "left"), - [BlockTypes.httpgetrequestbody]: node("Get Request Body", , "left"), - [BlockTypes.db_getsingle]: node("Get Single Record", , "left"), - [BlockTypes.db_getall]: node("Get All Records", , "left"), - [BlockTypes.db_insert]: node("Insert New Record", , "left"), - [BlockTypes.db_insertbulk]: node("Insert Bulk Record", , "left"), - [BlockTypes.db_update]: node("Update Record(s)", , "left"), - [BlockTypes.db_delete]: node("Delete Record(s)", , "left"), - [BlockTypes.db_native]: node("Native Database", , "left"), - [BlockTypes.db_transaction]: node("Database Transaction", , "left", [ - { type: "target", position: Position.Top }, - { type: "source", position: Position.Bottom }, - { type: "source", position: Position.Right, variant: "executor", color: GREEN }, - ]), - [BlockTypes.consolelog]: node("Console", , "left"), - [BlockTypes.cloudLogs]: node("Cloud Log store", , "left"), -}; diff --git a/apps/portal/src/routes/_authed/$projectId.tsx b/apps/portal/src/routes/_authed/$projectId.tsx index 48a63e8c..9410b125 100644 --- a/apps/portal/src/routes/_authed/$projectId.tsx +++ b/apps/portal/src/routes/_authed/$projectId.tsx @@ -23,6 +23,11 @@ import { authClient } from "@/lib/auth"; import { useAuthStore } from "@/store/auth"; import { createRouteHead } from "@/lib/seo"; +// BASE_URL, not a hardcoded path: files in public/ are served from the bundle +// root, so the literal "/_/admin/ui/public/..." resolved to nothing and the SPA +// fallback answered with index.html — an HTML body where an image was expected. +const logo = `${import.meta.env.BASE_URL}icons/logo.webp`; + const NAV = [ { key: "ai", label: "Fluxify AI", to: "/$projectId/ai", icon: TbSparkles }, { key: "routes", label: "Routes", to: "/$projectId/routes", icon: TbStack2 }, @@ -79,7 +84,7 @@ function ProjectLayout() { {/* Logo */}
- logo + logo
FLUXIFY diff --git a/apps/portal/src/routes/_authed/$projectId/routes.tsx b/apps/portal/src/routes/_authed/$projectId/routes.tsx index f5a9367f..19b512f3 100644 --- a/apps/portal/src/routes/_authed/$projectId/routes.tsx +++ b/apps/portal/src/routes/_authed/$projectId/routes.tsx @@ -94,7 +94,7 @@ function RoutesPage() {
- + - {route?.name ?? "Editor"} - {route?.method && ( - - {route.method} {route.path} - - )} -
- -
- {TABS.map((t) => ( - - ))} -
- -
- {route && ( - - toggle.mutate( - { id: routeId, active }, - { - onSuccess: () => - toast.success(active ? "Route enabled" : "Route disabled"), - onError: (e) => showErrorNotification(e as Error), - }, - ) - } - > - {route.active ? "Active" : "Inactive"} - - )} - -
- - -
- {tab !== "editor" ? ( -
- {tab === "executions" ? "Executions" : "Testing"} panel — coming soon. -
- ) : canvas.isLoading ? ( -
- -
- ) : canvas.isError ? ( -
- Couldn't load the flow. -
- ) : ( - - setDirty(true)} - /> - - )} -
-
- ); -} - -type FlowProps = { - nodes: Node[]; - edges: Edge[]; - onNodesChange: Parameters[0]["onNodesChange"]; - onEdgesChange: Parameters[0]["onEdgesChange"]; - setNodes: ReturnType>[1]; - setEdges: ReturnType>[1]; - markDirty: () => void; -}; - -function Flow({ nodes, edges, onNodesChange, onEdgesChange, setNodes, setEdges, markDirty }: FlowProps) { - const [paletteOpen, setPaletteOpen] = useState(false); - const { screenToFlowPosition } = useReactFlow(); - - function addBlock(type: string) { - const position = screenToFlowPosition({ - x: window.innerWidth / 2, - y: window.innerHeight / 2, - }); - setNodes((n) => [ - ...n, - { id: crypto.randomUUID(), type, position, data: { sources: [], targets: [] } }, - ]); - setPaletteOpen(false); - markDirty(); - toast.success("Block added"); - } - - function onConnect(conn: Connection) { - setEdges((e) => addEdge(conn, e)); - markDirty(); - } - - return ( - <> - { - onNodesChange?.(c); - if (c.some((ch) => ch.type !== "select" && ch.type !== "dimensions")) - markDirty(); - }} - onEdgesChange={(c) => { - onEdgesChange?.(c); - if (c.some((ch) => ch.type !== "select")) markDirty(); - }} - onConnect={onConnect} - nodeTypes={nodeTypes} - fitView - proOptions={{ hideAttribution: true }} - > - - - - - - - setPaletteOpen(false)} - onAdd={addBlock} - /> - - ); -} diff --git a/apps/portal/vite.config.ts b/apps/portal/vite.config.ts index bdb14f1a..cbc309b0 100644 --- a/apps/portal/vite.config.ts +++ b/apps/portal/vite.config.ts @@ -34,7 +34,10 @@ export default defineConfig({ }, }, server: { - port: 3001, + port: 3000, + // bind 0.0.0.0 so Caddy in Docker can reach us via host.docker.internal + host: true, + allowedHosts: ["localhost"], proxy: { // ws: the harness socket.io transport lives at /_/admin/api/ai/socket.io/ // and upgrades to a websocket; without this it never leaves long-polling. diff --git a/docker/kit/Caddyfile b/docker/kit/Caddyfile index d590fa8e..2e9e2f9b 100644 --- a/docker/kit/Caddyfile +++ b/docker/kit/Caddyfile @@ -1,7 +1,19 @@ # Single published port for the whole kit. Everything below is in-container. :8080 { + # Vite's base only serves the trailing-slash form; redirect the bare path. + @admin_ui_bare path /_/admin/ui + handle @admin_ui_bare { + redir * /_/admin/ui/ 308 + } + + # Admin UI: a static SPA built by Vite with base /_/admin/ui/. strip_prefix + # maps the public path onto the bundle root; the try_files fallback hands + # client-routed deep links back to index.html instead of 404ing. handle /_/admin/ui* { - reverse_proxy localhost:3000 + uri strip_prefix /_/admin/ui + root * /app/portal + try_files {path} /index.html + file_server } handle /_/admin/api/ai* { reverse_proxy localhost:8001 diff --git a/docker/kit/Dockerfile b/docker/kit/Dockerfile index 7a48439e..339c5717 100644 --- a/docker/kit/Dockerfile +++ b/docker/kit/Dockerfile @@ -5,8 +5,11 @@ # run it on a small VPS without wiring a stack together: # # caddy :8080 reverse proxy — the only port you need to publish +# postgres :5432 bundled, loopback only (skipped when PG_URL is set) +# valkey :6379 bundled, loopback only (skipped when REDIS_HOST is set) +# nats :4222 bundled, loopback only (skipped when NATS_URL is set) # admin server :5500 control plane API + the compiler -# web UI :3000 admin dashboard +# admin UI static Vite bundle served by caddy from /app/portal # ai-gateway :8001 AI assistant # compiled worker :5600 serves your API traffic from compiled JavaScript # :5601 worker health/readiness @@ -18,21 +21,49 @@ # BUILD (from the repo root, the CBE directory) # docker build -f docker/kit/Dockerfile -t fluxify-kit . # -# RUN -# docker run --rm -p 8080:8080 --env-file docker/kit/.env fluxify-kit +# RUN — nothing external needed +# docker run -p 8080:8080 -v fluxify_kit_data:/data \ +# -e SEED_USER_EMAIL=you@example.com -e SEED_USER_PASSWORD=changeme123 \ +# fluxify-kit # # Then open http://localhost:8080/_/admin/ui # # ------------------------------------------------------------------------------ +# BATTERIES INCLUDED +# PostgreSQL, Valkey and NATS ship inside the image and start automatically. +# Each one is skipped when its connection variable is already set, so pointing +# the kit at external services is opt-in and needs no different image: +# +# PG_URL set -> bundled PostgreSQL is not started +# REDIS_HOST set -> bundled Valkey is not started +# NATS_URL set -> bundled NATS is not started +# +# docker/kit/docker-compose.yml sets all three, so it runs the external +# topology unchanged. +# +# State lives in the /data volume: the Postgres cluster, the JetStream store, +# and secrets.env. MASTER_ENCRYPTION_KEY, BETTER_AUTH_SECRET and +# SYSTEM_ACCESS_KEY are generated there on first boot when not supplied. +# BACK IT UP — losing it means stored project credentials cannot be decrypted. +# +# NOT FOR PRODUCTION. Bundled Postgres cannot be major-version upgraded in +# place; a base-image bump would orphan the data directory. +# +# ------------------------------------------------------------------------------ # REQUIRED ENVIRONMENT +# SEED_USER_EMAIL first admin account — no default, and a fresh +# SEED_USER_PASSWORD database REFUSES to boot without both (min 8 chars). +# Nothing else can create the first login. +# SERVER_URL public URL, e.g. http://localhost:8080 +# BETTER_AUTH_URL same as SERVER_URL +# TRUSTED_ORIGINS comma-separated allowed origins +# +# REQUIRED ONLY WHEN USING EXTERNAL SERVICES # PG_URL postgres://user:pass@host:5432/fluxify # REDIS_HOST Redis/Valkey hostname # NATS_URL nats://host:4222 — JetStream MUST be on (`-js`) # MASTER_ENCRYPTION_KEY `openssl rand -base64 32`; encrypts project config # BETTER_AUTH_SECRET session signing secret -# SERVER_URL public URL, e.g. http://localhost:8080 -# BETTER_AUTH_URL same as SERVER_URL -# TRUSTED_ORIGINS comma-separated allowed origins # # REQUIRED ONCE YOU HAVE A PROJECT # WORKER_PROJECT_ID the project this kit serves. Leave it unset on the @@ -90,8 +121,10 @@ COPY . . # Required because turbo sub-package builds need per-workspace .bin symlinks. RUN bun install --frozen-lockfile -# Workspace packages + admin server (standalone.js + schema.sql) + web + gateway. -RUN bun run build +# Workspace packages + admin server (standalone.js + schema.sql) + gateway. +# --filter excludes @fluxify/web: the admin UI is apps/portal now, and the +# legacy Next.js app no longer typechecks against the current server schemas. +RUN bun run build --filter=!@fluxify/web # The compiled worker is TWO bundles, not one. Bun's bundler does not pull a # child-process entry point into its parent's bundle; it leaves the reference and resolves @@ -99,11 +132,6 @@ RUN bun run build # so this flat sibling layout in dist/ is load-bearing — do not nest it. RUN bun run --cwd apps/server build:worker:compiled -# Next.js standalone output does not include these; copy them in. -WORKDIR /app/apps/web -RUN cp -r public .next/standalone/apps/web/public && \ - cp -r .next/static .next/standalone/apps/web/.next/static - # AI Gateway documentation search index. WORKDIR /app/apps/ai-gateway RUN bun run gather @@ -132,14 +160,23 @@ LABEL org.opencontainers.image.title="Fluxify Kit" \ org.opencontainers.image.created="${BUILD_DATE}" # tini reaps the child processes the entrypoint spawns; caddy is the proxy. -RUN apk add --no-cache tini caddy nss-tools +# +# postgresql/valkey/nats-server are the bundled backing services. The major +# version of postgresql is PINNED deliberately: an unpinned bump would ship an +# image that refuses to mount an existing data directory, and there is no +# pg_upgrade path in a single-shot container. Treat a bump as breaking. +RUN apk add --no-cache tini caddy nss-tools \ + postgresql16 postgresql16-client valkey valkey-cli nats-server + +# initdb/postgres live outside /usr/bin on Alpine's versioned packages. +ENV PATH="/usr/libexec/postgresql16:${PATH}" RUN adduser -D -s /sbin/nologin -h /app appuser # Admin server bundle + compiled worker bundles + schema.sql, all siblings. COPY --from=build --chown=appuser:appuser /app/apps/server/dist /app/server -# Next.js standalone build. -COPY --from=build --chown=appuser:appuser /app/apps/web/.next/standalone /app/web +# Admin UI — a static Vite bundle, served directly by Caddy. No Node process. +COPY --from=build --chown=appuser:appuser /app/apps/portal/dist /app/portal # AI Gateway single-file bundle + docs index. COPY --from=build --chown=appuser:appuser /app/apps/ai-gateway/dist /app/ai-gateway @@ -147,14 +184,22 @@ COPY --chown=appuser:appuser docker/kit/Caddyfile /app/Caddyfile COPY --chown=appuser:appuser docker/kit/entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh +# Everything stateful the kit owns: the bundled Postgres cluster, the JetStream +# store, and the secrets generated on first boot. Declared so a bare +# `docker run` survives a restart; mount it explicitly to survive `rm`. +RUN mkdir -p /data && chown appuser:appuser /data +VOLUME /data + USER appuser # 8080 is the only port you need to publish; the rest are internal. -EXPOSE 8080 5500 5600 5601 3000 8001 +EXPOSE 8080 5500 5600 5601 8001 ENV NODE_ENV=production \ ENVIRONMENT=production \ HOSTNAME=0.0.0.0 \ + KIT_DATA_DIR=/data \ + REDIS_PORT=6379 \ WEB_PORT=3000 \ SERVER_PORT=5500 \ WORKER_PORT=5600 \ @@ -173,7 +218,7 @@ ENV NODE_ENV=production \ # Reports healthy once the admin API answers; the worker has its own probe on # 5601 because it may legitimately be absent (see WORKER_PROJECT_ID above). HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ - CMD wget -qO- http://localhost:5500/_/admin/api/healthchecks/startup || exit 1 + CMD wget -qO- http://localhost:5500/_/admin/api/public-settings || exit 1 # tini as PID 1 so signals reach every service and nothing is orphaned. ENTRYPOINT ["tini", "--"] diff --git a/docker/kit/entrypoint.sh b/docker/kit/entrypoint.sh index 7e2ca762..e8112279 100644 --- a/docker/kit/entrypoint.sh +++ b/docker/kit/entrypoint.sh @@ -3,23 +3,210 @@ set -e echo "[kit] Starting Fluxify services..." -pids="" -start() { "$@" & pids="$pids $!"; } +DATA_DIR="${KIT_DATA_DIR:-/data}" +PGDATA="$DATA_DIR/postgres" +NATS_STORE="$DATA_DIR/nats" +SECRETS_FILE="$DATA_DIR/secrets.env" + +# "name:pid name:pid ..." — names are only used to say WHICH service died. +services="" +start() { + _name="$1" + shift + "$@" & + services="$services $_name:$!" +} + +pids_of_services() { + for entry in $services; do + printf '%s ' "${entry##*:}" + done +} # Forward SIGTERM/SIGINT to every child, then wait for them to drain. # Without this trap the shell (PID 1 under tini) swallows the signal and the # backgrounded services are orphaned — `docker stop` hangs until SIGKILL. +shutting_down=0 term() { + shutting_down=1 echo "[kit] signal received — stopping services..." - kill -TERM $pids 2>/dev/null || true + # shellcheck disable=SC2046 + kill -TERM $(pids_of_services) 2>/dev/null || true wait exit 0 } trap term TERM INT +die() { + echo "[kit] FATAL: $1" >&2 + exit 1 +} + +# Polls a readiness command until it succeeds. Each service gets its own probe +# rather than a generic port check: busybox `nc` has no portable -z, and an open +# port does not mean postgres is accepting queries anyway. +wait_until() { + _label="$1" + shift + _n=0 + while [ "$_n" -lt 60 ]; do + if "$@" >/dev/null 2>&1; then + return 0 + fi + _n=$((_n + 1)) + sleep 1 + done + die "$_label did not become ready after 60s" +} + +# ------------------------------------------------------------------------------ +# Secrets — generated once on first boot and reused from the data volume. +# +# Without this a bare `docker run` fails: MASTER_ENCRYPTION_KEY and +# BETTER_AUTH_SECRET are required and have no safe default. Anything explicitly +# passed in the environment always wins over the generated file. +# ------------------------------------------------------------------------------ +provision_secrets() { + if [ -f "$SECRETS_FILE" ]; then + # shellcheck disable=SC1090 + . "$SECRETS_FILE" + fi + + _generated=0 + for var in MASTER_ENCRYPTION_KEY BETTER_AUTH_SECRET SYSTEM_ACCESS_KEY; do + eval "_current=\$$var" + if [ -z "$_current" ]; then + eval "$var=\$(head -c 32 /dev/urandom | base64 | tr -d '\n')" + _generated=1 + fi + eval "export $var" + done + + if [ "$_generated" = "1" ]; then + umask 077 + { + echo "# Generated by the Fluxify kit on first boot. Losing this file means" + echo "# stored project credentials can no longer be decrypted." + for var in MASTER_ENCRYPTION_KEY BETTER_AUTH_SECRET SYSTEM_ACCESS_KEY; do + eval "printf '%s=%s\n' \"$var\" \"\$$var\"" + done + } >"$SECRETS_FILE" + echo "[kit] generated instance secrets -> $SECRETS_FILE (back this up)" + fi +} + +# ------------------------------------------------------------------------------ +# Admin credentials. +# +# The seeder skips silently when these are missing or malformed, which leaves a +# running instance nobody can log into. Refuse to boot a fresh database instead +# — the failure is the same either way, but this one explains itself. +# ------------------------------------------------------------------------------ +require_admin_credentials() { + _why="$1" + [ -n "$SEED_USER_EMAIL" ] || + die "SEED_USER_EMAIL is required ($_why). Without it no admin account is created and the UI cannot be logged into." + [ -n "$SEED_USER_PASSWORD" ] || + die "SEED_USER_PASSWORD is required ($_why). Without it no admin account is created and the UI cannot be logged into." + case "$SEED_USER_EMAIL" in + ?*@?*.?*) ;; + *) die "SEED_USER_EMAIL ('$SEED_USER_EMAIL') is not a valid email address; the seeder would reject it and create no admin account." ;; + esac + [ "${#SEED_USER_PASSWORD}" -ge 8 ] || + die "SEED_USER_PASSWORD must be at least 8 characters; the seeder would reject it and create no admin account." +} + +# ------------------------------------------------------------------------------ +# Bundled backing services. +# +# Each starts only when its connection variable is absent, so setting PG_URL / +# NATS_URL / REDIS_HOST (as docker-compose.yml does) opts out of the bundled +# copy and points the stack at an external one. Same image either way. +# ------------------------------------------------------------------------------ +start_bundled_postgres() { + _fresh=0 + if [ ! -s "$PGDATA/PG_VERSION" ]; then + _fresh=1 + # Fresh database => nobody has an account yet. Check before initdb so a + # misconfigured run fails in a second rather than after a full boot. + require_admin_credentials "this is a first boot with the bundled database" + echo "[kit] initialising bundled PostgreSQL in $PGDATA..." + mkdir -p "$PGDATA" + chmod 700 "$PGDATA" + # trust auth is safe here: postgres listens on loopback inside this + # container only and the port is never published. + initdb --username=postgres --auth-local=trust --auth-host=trust \ + --encoding=UTF8 --pgdata="$PGDATA" >/dev/null + fi + + echo "[kit] starting bundled PostgreSQL..." + start postgres postgres -D "$PGDATA" \ + -c listen_addresses=127.0.0.1 \ + -c port=5432 \ + -c unix_socket_directories="$DATA_DIR" + + wait_until "bundled PostgreSQL" pg_isready -h 127.0.0.1 -p 5432 -U postgres -q + + if [ "$_fresh" = "1" ]; then + createdb -h 127.0.0.1 -U postgres fluxify + echo "[kit] created database 'fluxify'" + fi + + export PG_URL="postgres://postgres@127.0.0.1:5432/fluxify" +} + +start_bundled_nats() { + echo "[kit] starting bundled NATS (JetStream)..." + mkdir -p "$NATS_STORE" + # -js is REQUIRED: the compile work queue is a JetStream stream and compiled + # routes live in a KV bucket, which is built on JetStream. + # -m enables the monitoring endpoint, which is how we probe readiness. + if [ -n "$NATS_TOKEN" ]; then + start nats nats-server -js --store_dir "$NATS_STORE" \ + --addr 127.0.0.1 --port 4222 -m 8222 --auth "$NATS_TOKEN" + else + start nats nats-server -js --store_dir "$NATS_STORE" \ + --addr 127.0.0.1 --port 4222 -m 8222 + fi + wait_until "bundled NATS" wget -qO- http://127.0.0.1:8222/healthz + export NATS_URL="nats://127.0.0.1:4222" +} + +start_bundled_valkey() { + echo "[kit] starting bundled Valkey..." + # Cache only — pub/sub moved to NATS — so persistence buys nothing and an + # unwritable/half-written dump would just be a new failure mode. + start valkey valkey-server --save '' --appendonly no \ + --bind 127.0.0.1 --port 6379 + wait_until "bundled Valkey" valkey-cli -h 127.0.0.1 -p 6379 ping + export REDIS_HOST=127.0.0.1 + export REDIS_PORT="${REDIS_PORT:-6379}" + unset REDIS_USER REDIS_PASS +} + +mkdir -p "$DATA_DIR" +provision_secrets + +[ -n "$PG_URL" ] || start_bundled_postgres +[ -n "$NATS_URL" ] || start_bundled_nats +[ -n "$REDIS_HOST" ] || start_bundled_valkey + +# External database: we cannot tell whether it is already seeded, so warn rather +# than refuse — an existing deployment restarting must not be blocked. +if [ -z "$SEED_USER_EMAIL" ] || [ -z "$SEED_USER_PASSWORD" ]; then + echo "[kit] WARNING: SEED_USER_EMAIL / SEED_USER_PASSWORD are unset. If this" + echo "[kit] database has no admin account yet, nothing can log in." +fi + # Admin/control-plane server. Runs migrations and hosts the compiler, which # turns saved routes into JavaScript and publishes them to the NATS KV bucket. -start bun --cwd=/app/server standalone.js +start server bun --cwd=/app/server standalone.js + +# Everything downstream reads tables the admin server creates, so nothing else +# may start until its migrations have run. The AI gateway has no schema-wait of +# its own and dies outright on a missing app_config. +wait_until "admin server" \ + wget -qO- "http://127.0.0.1:${SERVER_PORT:-5500}/_/admin/api/public-settings" # Compiled request worker — serves user API traffic from those artifacts. # @@ -27,22 +214,37 @@ start bun --cwd=/app/server standalone.js # On a first boot that is the normal state: bring the kit up without it, create # a project in the UI, then set WORKER_PROJECT_ID and restart. if [ -n "$WORKER_PROJECT_ID" ]; then - start bun --cwd=/app/server compiledWorker.js + start worker bun --cwd=/app/server compiledWorker.js else echo "[kit] WORKER_PROJECT_ID is not set — starting without the request worker." echo "[kit] Create a project at /_/admin/ui, then set WORKER_PROJECT_ID and restart." fi -# Next.js admin UI -start bun --cwd=/app/web apps/web/server.js +# The admin UI is a static Vite bundle in /app/portal, served by Caddy — there +# is no UI process to start. # AI Gateway -start bun --cwd=/app/ai-gateway server.js +start ai-gateway bun --cwd=/app/ai-gateway server.js # Reverse proxy — the single published port -start caddy run --config /app/Caddyfile +start caddy caddy run --config /app/Caddyfile echo "[kit] All services launched." -# Wait for the background processes (or the trap) to finish. +# Supervision: a bare `wait` blocks until EVERY child exits, so one dead service +# leaves the container "up" and quietly broken. Poll instead and exit non-zero +# on the first death — `restart: unless-stopped` then restarts the whole kit. +# ponytail: whole-container restart, not per-service; add a real supervisor +# (s6-overlay) only if partial restarts turn out to matter. +while [ "$shutting_down" = "0" ]; do + for entry in $services; do + if ! kill -0 "${entry##*:}" 2>/dev/null; then + echo "[kit] service '${entry%%:*}' exited — stopping the container" >&2 + kill -TERM $(pids_of_services) 2>/dev/null || true + exit 1 + fi + done + sleep 5 +done + wait diff --git a/docker/kit/env.example b/docker/kit/env.example index 39026cad..0bd5d468 100644 --- a/docker/kit/env.example +++ b/docker/kit/env.example @@ -4,6 +4,20 @@ ENVIRONMENT=production # Node environment mode: development | production NODE_ENV=production +# ====================== BUNDLED vs EXTERNAL SERVICES ====================== +# The kit image ships PostgreSQL, Valkey and NATS and starts them automatically. +# Each is SKIPPED when its connection variable below is set, which is exactly +# what this file does — so docker-compose.yml runs the external containers and a +# bare `docker run` (no env file) runs the bundled ones. Same image both ways. +# +# PG_URL set -> bundled PostgreSQL not started +# REDIS_HOST set -> bundled Valkey not started +# NATS_URL set -> bundled NATS not started +# +# In bundled mode MASTER_ENCRYPTION_KEY / BETTER_AUTH_SECRET / SYSTEM_ACCESS_KEY +# are generated into /data/secrets.env on first boot if unset. Back that up: +# losing it means stored project credentials can no longer be decrypted. + # ====================== DATABASES ====================== # PostgreSQL connection string: postgres://{username}:{password}@{host}:{port}/{database} PG_URL=postgres://postgres:postgres@postgres:5432/fluxify_alpha @@ -80,9 +94,10 @@ BETTER_AUTH_URL=http://localhost:8080 SYSTEM_ACCESS_KEY=2zhJ7KpBp4s6WECoCHsG3ss6SwZuldw6 # Disable npm package installations in execution environment (true | false) DISABLE_NPM=true -# Default email address for initial admin user seed +# Initial admin account. REQUIRED — a first boot with the bundled database +# refuses to start without both, because nothing else can create a login and the +# seeder skips silently on a malformed value. Password must be 8+ characters. SEED_USER_EMAIL=admin@company.com -# Default password for initial admin user seed SEED_USER_PASSWORD=admin@123 # Default full name for initial admin user seed SEED_USER_NAME=Admin user diff --git a/docs/deployments/index.md b/docs/deployments/index.md index a45a334b..f77e84fd 100644 --- a/docs/deployments/index.md +++ b/docs/deployments/index.md @@ -25,7 +25,7 @@ steps for each. | | **Kit** (all-in-one) | **Admin + Workers** (scale-out) | | :--- | :--- | :--- | | **Best for** | Trials, demos, single-machine hosting | Real production traffic | -| **Containers** | One app container | Separate admin + many workers | +| **Containers** | One — database, cache and event bus included | Separate admin + many workers | | **Scaling** | Vertical only (bigger machine) | Horizontal — add workers on demand | | **Edge proxy** | Built into the image | Traefik (load-balances the workers) | | **Setup effort** | Lowest — one command | Moderate | @@ -63,8 +63,8 @@ flowchart TB ADM["Admin API
+ compiler"] WRK["Request worker"] AI["AI gateway"] + BS["PostgreSQL · Valkey · NATS
(bundled, or bring your own)"] end - K --> BS["PostgreSQL · Valkey · NATS"] style C fill:#111113,stroke:#EF4444,color:#FAFAFA style K fill:#111113,stroke:#D2FF4D,color:#FAFAFA @@ -182,7 +182,8 @@ projects can only resolve to one of them, and a slow project slows the rest. Pick your path and jump straight to the steps: -- **Kit:** [create your `.env`](./kit#env) → [start the stack](./kit#start) +- **Kit:** [run one command](./kit#bundled) → + [turn on the request worker](./kit#worker) - **Admin + Workers:** [why Traefik](./production#why-traefik) → [create your `.env`](./production#env) → [start the stack](./production#start) → [scale the workers](./production#scaling) diff --git a/docs/deployments/kit.md b/docs/deployments/kit.md index 854c00b3..bf4745a9 100644 --- a/docs/deployments/kit.md +++ b/docs/deployments/kit.md @@ -1,14 +1,17 @@ --- title: Quick Run with the Kit Image -description: Run all of Fluxify in a single container using the fluxify-kit image — ideal for local trials, demos, and evaluation. Includes a ready-to-use Docker Compose stack. +description: Run all of Fluxify in a single container using the fluxify-kit image — ideal for local trials, demos, and evaluation. Includes a batteries-included single command and a Docker Compose stack. --- # Quick Run with the Kit Image The **Kit** image (`fluxify-kit`) bundles everything Fluxify needs into a single -container: the admin API, the request worker, the web dashboard, the AI gateway, -and a built-in proxy. One container, one port, one command — perfect for **local -trials, demos, and evaluation**. +container: the admin API, the request worker, the dashboard, the AI gateway, and +a built-in proxy. It also ships its own database, cache, and event bus, so you +can start the whole thing with one command and nothing else installed. + +One container, one port, one command — perfect for **local trials, demos, and +evaluation**. > [!TIP] > Running a real production instance? Use the [Production Setup](./production) @@ -21,7 +24,7 @@ trials, demos, and evaluation**. | Item | Value | | :--- | :--- | -| Containers to run | 1 app container + Postgres + Valkey + NATS | +| Containers to run | 1 (or 4, if you supply your own database, cache, and event bus) | | Public port | `8080` | | Best for | Trials, demos, single-machine self-hosting | | Scaling | Vertical only (bigger machine) | @@ -30,14 +33,90 @@ Traffic enters on port `8080` and is routed for you: | URL | Goes to | | :--- | :--- | -| `http://localhost:8080/_/admin/ui` | Web dashboard (visual editor) | +| `http://localhost:8080/_/admin/ui` | Dashboard (visual editor) | | `http://localhost:8080/_/admin/api` | Admin REST API | | `http://localhost:8080/_/admin/api/openapi/ui` | API documentation | | `http://localhost:8080/` | Your published workflows & custom endpoints | --- -## Step 1 — Create your `.env` {#env} +## Two ways to run it + +Pick one. Both use the same image. + +| | **Bundled** (recommended for trials) | **Bring your own services** | +| :--- | :--- | :--- | +| Command | `docker run` | `docker compose` | +| Database, cache, event bus | Included in the container | You supply them | +| Setup | One command, no config file | Copy and edit an environment file | +| Good for | Trying Fluxify out today | Keeping data in a database you manage | + +The image decides automatically: if you tell it where to find a database, cache, +or event bus, it uses yours. If you don't, it starts its own. + +--- + +## Option A — One command {#bundled} + +Nothing to install, no configuration file: + +```bash +docker run -d --name fluxify \ + -p 8080:8080 \ + -v fluxify_data:/data \ + -e SEED_USER_EMAIL=admin@example.com \ + -e SEED_USER_PASSWORD=ChangeThisPassword123! \ + fluxify-kit +``` + +Then open `http://localhost:8080/_/admin/ui` and log in with the email and +password you just set. + +> [!IMPORTANT] +> `SEED_USER_EMAIL` and `SEED_USER_PASSWORD` are **required** and have no +> defaults. They create the first administrator account, and nothing else can +> create it for you. A brand-new instance refuses to start without them rather +> than starting up with no way to log in. The password must be at least 8 +> characters. + +### About that `-v fluxify_data:/data` + +Everything the kit stores lives in `/data`: your projects and routes, the event +history, and the security keys it generates the first time it starts. + +**Don't skip the volume.** Without it, removing the container throws all of that +away. + +> [!WARNING] +> On first start the kit generates its own encryption and session keys and saves +> them in `/data`. **Back this up.** If you lose it, saved credentials — database +> passwords, integration keys — can no longer be read, even with the same +> projects restored. + +### Changing the port + +If `8080` is taken, map a different one on the left-hand side and tell Fluxify +its public address: + +```bash +docker run -d --name fluxify \ + -p 9090:8080 \ + -v fluxify_data:/data \ + -e SERVER_URL=http://localhost:9090 \ + -e BETTER_AUTH_URL=http://localhost:9090 \ + -e TRUSTED_ORIGINS=http://localhost:9090 \ + -e SEED_USER_EMAIL=admin@example.com \ + -e SEED_USER_PASSWORD=ChangeThisPassword123! \ + fluxify-kit +``` + +--- + +## Option B — Bring your own database, cache, and event bus {#compose} + +Use this when you want your data in a database you manage and back up yourself. + +### Step 1 — Create your `.env` Copy `docker/kit/env.example` to `docker/kit/.env` next to the compose file: @@ -45,19 +124,18 @@ Copy `docker/kit/env.example` to `docker/kit/.env` next to the compose file: cp docker/kit/env.example docker/kit/.env ``` -At minimum verify the key environment variables: +At minimum verify these values: ```env #====================== ENVIRONMENT ====================== NODE_ENV=production ENVIRONMENT=production -#====================== DATABASES ====================== +#====================== YOUR OWN SERVICES ====================== +# Setting these three switches off the built-in copies. PG_URL=postgres://postgres:postgres@postgres:5432/fluxify_alpha REDIS_HOST=valkey REDIS_PORT=6379 - -#====================== EVENT BUS ====================== NATS_URL=nats://nats:4222 NATS_TOKEN=fluxify_nats_token @@ -81,9 +159,10 @@ SEED_USER_NAME=Admin User > Back up `MASTER_ENCRYPTION_KEY`. If you lose or change it after storing data, > every saved credential becomes unreadable. -> [!IMPORTANT] -> `SEED_USER_EMAIL` / `SEED_USER_PASSWORD` create the first admin account on the -> **first run only**. Set them before you start the stack. +> [!NOTE] +> When you supply your own services you must also supply your own +> `MASTER_ENCRYPTION_KEY` and `BETTER_AUTH_SECRET`. The kit only generates those +> for you in bundled mode, where it has somewhere of its own to keep them. ### Generate your secret keys @@ -92,24 +171,16 @@ Use this generator to create secure values for `MASTER_ENCRYPTION_KEY` and ---- - -## Step 2 — Start the stack {#start} - -Use the ready-made compose file from the repository: +### Step 2 — Start the stack ```bash docker compose -f docker/kit/docker-compose.yml up -d ``` -This starts four containers: the Fluxify Kit plus its Postgres, Valkey, and NATS -dependencies. Database setup runs automatically on first boot. +This starts four containers: Fluxify plus the database, cache, and event bus. +Database setup runs automatically on first boot. ---- - -## Step 3 — Open the dashboard - -Once the containers are healthy, open: +### Step 3 — Open the dashboard ``` http://localhost:8080/_/admin/ui @@ -119,7 +190,9 @@ Log in with the seed admin credentials from your `.env`. --- -## Step 4 — Create a project and switch on the request worker {#worker} +## Turning on the request worker {#worker} + +This part applies to **both** options. The first time you start, you'll see this in the logs: @@ -136,38 +209,48 @@ So: 1. **Create a project** in the dashboard. 2. **Copy its id** from the project's settings page. -3. **Put it in `docker/kit/.env`:** +3. **Set `WORKER_PROJECT_ID` to that id** — in `docker/kit/.env` for Option B, or + as another `-e WORKER_PROJECT_ID=...` for Option A. +4. **Start the container again** with the new value. - ```env - WORKER_PROJECT_ID= - ``` +Your routes are then served at `http://localhost:8080/`. -4. **Bring the stack up again:** - - ```bash - docker compose -f docker/kit/docker-compose.yml up -d - ``` - -The worker now starts, and `http://localhost:8080/` serves your routes. +> [!TIP] +> Prefer not to do this at all? Set `WORKER_PROJECT_ID=*` from the very first +> start and the kit serves **every** project you create, picking up new ones +> immediately with no restart. The catch: two projects that define the same path +> — say both have a `/users` endpoint — collide, and only one of them answers. +> Fine while you have one project, which is the usual case for a trial. To opt into experimental CPU-stall protection, set the project setting -`experimental.workerTimeouts.enabled` to `true`. The worker receives that -change through NATS immediately; no Docker restart is required. +`experimental.workerTimeouts.enabled` to `true`. The worker receives that change +immediately; no restart is required. > [!TIP] > From here on, saving a route in the editor publishes it to the worker in place -> — no restart and no redeploy. You only ever do this step again if you switch -> the kit to a different project. See +> — no restart and no redeploy. You only repeat this step if you point the kit at +> a different project. See > [Request Lifecycle](/architecture/request-lifecycle) for what happens on save. > [!NOTE] -> One kit serves one project. To serve several, move to the -> [Production Setup](./production), which runs a worker group per project. +> One kit serves one project (unless you use `*` above, with the caveat noted). +> To serve several properly, move to the [Production Setup](./production), which +> runs a worker group per project. --- ## Upgrading +**Option A:** + +```bash +docker pull fluxify-kit +docker rm -f fluxify +# then run the same `docker run` command again — your /data volume is reused +``` + +**Option B:** + ```bash docker compose -f docker/kit/docker-compose.yml pull docker compose -f docker/kit/docker-compose.yml up -d @@ -175,33 +258,55 @@ docker compose -f docker/kit/docker-compose.yml up -d Any required database updates run automatically at startup. +> [!WARNING] +> In bundled mode, a major upgrade of the built-in database cannot be applied to +> data already on disk. Upgrades that change it are announced in the release +> notes, and the safe path is to export what you need before upgrading. This is +> one of the reasons the kit isn't meant for production — see +> [Production Setup](./production). + --- ## Troubleshooting -**Container exits immediately** -Check the logs: `docker compose -f docker/kit/docker-compose.yml logs fluxify`. -The most common cause is a bad `PG_URL` or a `NATS_TOKEN` that doesn't match the -one passed to the NATS container. +**Container exits immediately, log says an admin email or password is required** +The first administrator account can only come from `SEED_USER_EMAIL` and +`SEED_USER_PASSWORD`. Set both (password 8+ characters) and start it again. + +**Container exits immediately, other causes** +Check the logs: `docker logs fluxify` (Option A) or +`docker compose -f docker/kit/docker-compose.yml logs fluxify` (Option B). In +Option B the usual cause is a bad `PG_URL`, or a `NATS_TOKEN` that doesn't match +the one given to the event bus container. + +**The container stops when one part of it fails** +That's deliberate. If any internal service dies, the whole container exits so +your restart policy brings it back, instead of leaving it running and quietly +broken. The log line naming the failed service is the last one printed. **Can't log in after first run** -The seed admin is created only on the very first boot. Confirm `SEED_USER_EMAIL` -and `SEED_USER_PASSWORD` were set **before** the stack started. +The seed admin is created only on the very first boot, against an empty +database. If you changed the values afterwards, they had no effect — reset the +password from the dashboard, or start over with a fresh volume. + +**Everything was working, then a restart lost all my projects** +You most likely ran without `-v fluxify_data:/data`. Data lives in that volume; +without it, removing the container discards everything. **Port 8080 already in use** -Change the host side of the mapping in the compose file (for example -`"9090:8080"`) and update `BETTER_AUTH_URL` to match. +Map a different host port and update `SERVER_URL`, `BETTER_AUTH_URL`, and +`TRUSTED_ORIGINS` to match. See [Changing the port](#bundled). **Requests to `/` return 502 Bad Gateway** The request worker isn't running. Almost always this means `WORKER_PROJECT_ID` -is empty — see [Step 4](#worker). Check with -`docker compose -f docker/kit/docker-compose.yml logs fluxify | grep WORKER_PROJECT_ID`. - -**Routes save fine but never go live** -NATS needs JetStream enabled. The bundled compose file starts it with `-js` -already; if you swapped in your own NATS, add that flag. +is empty — see [Turning on the request worker](#worker). Confirm with +`docker logs fluxify | grep WORKER_PROJECT_ID`. **Requests to `/` return 404 Route not found** -The worker is running but hasn't been given that route. Confirm the route is -marked active and belongs to the project in `WORKER_PROJECT_ID`, then save it -again to trigger a fresh publish. +The worker is running but doesn't have that route. Confirm the route is marked +active and belongs to the project in `WORKER_PROJECT_ID`, then save it again to +publish it. + +**Routes save fine but never go live** +The event bus needs JetStream enabled. The bundled copy and the supplied compose +file both do this already; if you swapped in your own, start it with `-js`. diff --git a/img/banner.png b/img/banner.png index 8e8bba5c..0d9d4d80 100644 Binary files a/img/banner.png and b/img/banner.png differ diff --git a/img/logo_title.png b/img/logo_title.png index 89e739f7..3a510077 100644 Binary files a/img/logo_title.png and b/img/logo_title.png differ diff --git a/package.json b/package.json index 884339e4..4e3b48f2 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,11 @@ "version": "1.0.0", "type": "module", "scripts": { - "dev": "concurrently 'bun run dev:server' 'bun run dev:worker' 'bun run dev:web' 'bun run dev:ai' 'bun run dev:docs'", + "dev": "concurrently 'bun run dev:server' 'bun run dev:worker' 'bun run dev:portal' 'bun run dev:ai' 'bun run dev:docs'", "dev:server": "bun run --env-file=.env --watch ./apps/server/deployments/standalone.ts", "dev:worker": "bun run --env-file=.env --watch ./apps/server/deployments/compiledWorker.ts", "dev:worker:dag": "bun run --env-file=.env --watch ./apps/server/deployments/worker.ts", - "dev:web": "turbo run dev --filter=@fluxify/web", + "dev:portal": "turbo run dev --filter=@fluxify/portal", "dev:ai": "bun run --env-file=.env --watch ./apps/ai-gateway/src/server.ts", "dev:docs": "vitepress dev docs", "build": "turbo run build",