diff --git a/.npmrc b/.npmrc
index a4994ac..0ef8e44 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1,2 +1 @@
side-effects-cache = false
-public-hoist-pattern[]=*hono*
diff --git a/Dockerfile b/Dockerfile
index 7797a43..efcfcdd 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,15 @@
+FROM golang:1.24 AS agent-build
+WORKDIR /app
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY agent/ ./agent
+RUN CGO_ENABLED=0 GOOS=linux go build \
+ -trimpath \
+ -ldflags "-s -w" \
+ -o /app/hp_agent ./agent/cmd/hp_agent
+
FROM node:22-alpine AS build
WORKDIR /app
@@ -11,8 +23,12 @@ COPY . .
RUN pnpm run build
FROM node:22-alpine
+RUN apk add --no-cache ca-certificates
RUN mkdir -p /var/lib/headplane
+RUN mkdir -p /usr/libexec/headplane
WORKDIR /app
COPY --from=build /app/build /app/build
+COPY --from=agent-build /app/hp_agent /usr/libexec/headplane/agent
+RUN chmod +x /usr/libexec/headplane/agent
CMD [ "node", "./build/server/index.js" ]
diff --git a/agent/cmd/hp_agent/hp_agent.go b/agent/cmd/hp_agent/hp_agent.go
index 61ffc08..8212059 100644
--- a/agent/cmd/hp_agent/hp_agent.go
+++ b/agent/cmd/hp_agent/hp_agent.go
@@ -8,6 +8,11 @@ import (
"github.com/tale/headplane/agent/internal/util"
)
+type Register struct {
+ Type string
+ ID string
+}
+
func main() {
log := util.GetLogger()
cfg, err := config.Load()
@@ -21,11 +26,10 @@ func main() {
agent.Connect()
defer agent.Shutdown()
- ws, err := hpagent.NewSocket(agent, cfg)
- if err != nil {
- log.Fatal("Failed to create websocket: %s", err)
- }
+ log.Msg(&Register{
+ Type: "register",
+ ID: agent.ID,
+ })
- defer ws.StopListening()
- ws.FollowMaster()
+ hpagent.FollowMaster(agent)
}
diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go
index 64f14a3..d33c014 100644
--- a/agent/internal/config/config.go
+++ b/agent/internal/config/config.go
@@ -8,8 +8,7 @@ type Config struct {
Hostname string
TSControlURL string
TSAuthKey string
- HPControlURL string
- HPAuthKey string
+ WorkDir string
}
const (
@@ -17,8 +16,7 @@ const (
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
- HPControlURLEnv = "HEADPLANE_AGENT_HP_SERVER"
- HPAuthKeyEnv = "HEADPLANE_AGENT_HP_AUTHKEY"
+ WorkDirEnv = "HEADPLANE_AGENT_WORK_DIR"
)
// Load reads the agent configuration from environment variables.
@@ -28,8 +26,7 @@ func Load() (*Config, error) {
Hostname: os.Getenv(HostnameEnv),
TSControlURL: os.Getenv(TSControlURLEnv),
TSAuthKey: os.Getenv(TSAuthKeyEnv),
- HPControlURL: os.Getenv(HPControlURLEnv),
- HPAuthKey: os.Getenv(HPAuthKeyEnv),
+ WorkDir: os.Getenv(WorkDirEnv),
}
if os.Getenv(DebugEnv) == "true" {
@@ -44,9 +41,5 @@ func Load() (*Config, error) {
return nil, err
}
- if err := validateHPReady(c); err != nil {
- return nil, err
- }
-
return c, nil
}
diff --git a/agent/internal/config/preflight.go b/agent/internal/config/preflight.go
index 3f74958..9634d8c 100644
--- a/agent/internal/config/preflight.go
+++ b/agent/internal/config/preflight.go
@@ -16,16 +16,12 @@ func validateRequired(config *Config) error {
return fmt.Errorf("%s is required", TSControlURLEnv)
}
- if config.HPControlURL == "" {
- return fmt.Errorf("%s is required", HPControlURLEnv)
- }
-
if config.TSAuthKey == "" {
return fmt.Errorf("%s is required", TSAuthKeyEnv)
}
- if config.HPAuthKey == "" {
- return fmt.Errorf("%s is required", HPAuthKeyEnv)
+ if config.WorkDir == "" {
+ return fmt.Errorf("%s is required", WorkDirEnv)
}
return nil
@@ -50,23 +46,3 @@ func validateTSReady(config *Config) error {
return nil
}
-
-// Pings the Headplane server to make sure it's up and running
-func validateHPReady(config *Config) error {
- testURL := config.HPControlURL
- if strings.HasSuffix(testURL, "/") {
- testURL = testURL[:len(testURL)-1]
- }
-
- testURL = fmt.Sprintf("%s/healthz", testURL)
- resp, err := http.Get(testURL)
- if err != nil {
- return fmt.Errorf("Failed to connect to HP control server: %s", err)
- }
-
- if resp.StatusCode != 200 {
- return fmt.Errorf("Failed to connect to HP control server: %s", resp.Status)
- }
-
- return nil
-}
diff --git a/agent/internal/hpagent/handler.go b/agent/internal/hpagent/handler.go
index 8523bf1..1228194 100644
--- a/agent/internal/hpagent/handler.go
+++ b/agent/internal/hpagent/handler.go
@@ -1,9 +1,12 @@
package hpagent
import (
+ "bufio"
"encoding/json"
+ "os"
"sync"
+ "github.com/tale/headplane/agent/internal/tsnet"
"github.com/tale/headplane/agent/internal/util"
"tailscale.com/tailcfg"
)
@@ -13,30 +16,32 @@ type RecvMessage struct {
NodeIDs []string
}
-// Starts listening for messages from the Headplane master
-func (s *Socket) FollowMaster() {
- log := util.GetLogger()
+type SendMessage struct {
+ Type string
+ Data any
+}
- for {
- _, message, err := s.ReadMessage()
- if err != nil {
- log.Error("Error reading message: %s", err)
- return
- }
+// Starts listening for messages from stdin
+func FollowMaster(agent *tsnet.TSAgent) {
+ log := util.GetLogger()
+ scanner := bufio.NewScanner(os.Stdin)
+
+ for scanner.Scan() {
+ line := scanner.Bytes()
var msg RecvMessage
- err = json.Unmarshal(message, &msg)
+ err := json.Unmarshal(line, &msg)
if err != nil {
log.Error("Unable to unmarshal message: %s", err)
log.Debug("Full Error: %v", err)
continue
}
- log.Debug("Recieved message from master: %v", message)
+ log.Debug("Recieved message from master: %v", line)
if len(msg.NodeIDs) == 0 {
log.Debug("Message recieved had no node IDs")
- log.Debug("Full message: %s", message)
+ log.Debug("Full message: %s", line)
continue
}
@@ -49,7 +54,7 @@ func (s *Socket) FollowMaster() {
wg.Add(1)
go func(nodeID string) {
defer wg.Done()
- result, err := s.Agent.GetStatusForPeer(nodeID)
+ result, err := agent.GetStatusForPeer(nodeID)
if err != nil {
log.Error("Unable to get status for node %s: %s", nodeID, err)
return
@@ -70,15 +75,13 @@ func (s *Socket) FollowMaster() {
// Send the results back to the Headplane master
log.Debug("Sending status back to master: %v", results)
- err = s.SendStatus(results)
- if err != nil {
- log.Error("Error sending status: %s", err)
- return
- }
+ log.Msg(&SendMessage{
+ Type: "status",
+ Data: results,
+ })
+ }
+
+ if err := scanner.Err(); err != nil {
+ log.Fatal("Error reading from stdin: %s", err)
}
}
-
-// Stops listening for messages from the Headplane master
-func (s *Socket) StopListening() {
- s.Close()
-}
diff --git a/agent/internal/hpagent/sender.go b/agent/internal/hpagent/sender.go
deleted file mode 100644
index 10b95eb..0000000
--- a/agent/internal/hpagent/sender.go
+++ /dev/null
@@ -1,11 +0,0 @@
-package hpagent
-
-import (
- "tailscale.com/tailcfg"
-)
-
-// Sends the status to the Headplane master
-func (s *Socket) SendStatus(status map[string]*tailcfg.HostinfoView) error {
- err := s.WriteJSON(status)
- return err
-}
diff --git a/agent/internal/hpagent/websocket.go b/agent/internal/hpagent/websocket.go
deleted file mode 100644
index faffe71..0000000
--- a/agent/internal/hpagent/websocket.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package hpagent
-
-import (
- "fmt"
- "net/http"
- "net/url"
-
- "github.com/gorilla/websocket"
- "github.com/tale/headplane/agent/internal/config"
- "github.com/tale/headplane/agent/internal/tsnet"
- "github.com/tale/headplane/agent/internal/util"
-)
-
-type Socket struct {
- *websocket.Conn
- Agent *tsnet.TSAgent
-}
-
-// Creates a new websocket connection to the Headplane server.
-func NewSocket(agent *tsnet.TSAgent, cfg *config.Config) (*Socket, error) {
- log := util.GetLogger()
-
- wsURL, err := httpToWs(cfg.HPControlURL)
- if err != nil {
- return nil, err
- }
-
- headers := http.Header{}
- headers.Add("X-Headplane-Tailnet-ID", agent.ID)
- auth := fmt.Sprintf("Bearer %s", cfg.HPAuthKey)
- headers.Add("Authorization", auth)
-
- log.Info("Dialing WebSocket with master: %s", wsURL)
- ws, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
- if err != nil {
- log.Debug("Failed to dial WebSocket: %s", err)
- return nil, err
- }
-
- return &Socket{ws, agent}, nil
-}
-
-// We need to convert the control URL to a websocket URL
-func httpToWs(controlURL string) (string, error) {
- log := util.GetLogger()
- u, err := url.Parse(controlURL)
- if err != nil {
- log.Debug("Failed to parse control URL: %s", err)
- return "", err
- }
-
- if u.Scheme == "http" {
- u.Scheme = "ws"
- } else if u.Scheme == "https" {
- u.Scheme = "wss"
- } else {
- return "", fmt.Errorf("unsupported scheme: %s", u.Scheme)
- }
-
- // We also need to append /_dial to the path
- if u.Path[len(u.Path)-1] != '/' {
- u.Path += "/"
- }
-
- u.Path += "_dial"
- return u.String(), nil
-}
diff --git a/agent/internal/tsnet/server.go b/agent/internal/tsnet/server.go
index f41c73a..eb4ffe8 100644
--- a/agent/internal/tsnet/server.go
+++ b/agent/internal/tsnet/server.go
@@ -2,6 +2,9 @@ package tsnet
import (
"context"
+ "os"
+ "path/filepath"
+
"github.com/tale/headplane/agent/internal/config"
"github.com/tale/headplane/agent/internal/util"
"tailscale.com/client/tailscale"
@@ -17,15 +20,27 @@ type TSAgent struct {
// Creates a new tsnet agent and returns an instance of the server.
func NewAgent(cfg *config.Config) *TSAgent {
+ log := util.GetLogger()
+
+ dir, err := filepath.Abs(cfg.WorkDir)
+ if err != nil {
+ log.Fatal("Failed to get absolute path: %s", err)
+ }
+
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ log.Fatal("Cannot create agent work directory: %s", err)
+ }
+
server := &tsnet.Server{
+ Dir: dir,
Hostname: cfg.Hostname,
ControlURL: cfg.TSControlURL,
AuthKey: cfg.TSAuthKey,
- Logf: func(string, ...interface{}) {}, // Disabled by default
+ Logf: func(string, ...any) {}, // Disabled by default
+ UserLogf: log.Info,
}
if cfg.Debug {
- log := util.GetLogger()
server.Logf = log.Debug
}
@@ -47,8 +62,13 @@ func (s *TSAgent) Connect() {
log.Fatal("Failed to initialize local Tailscale client: %s", err)
}
+ id, err := status.Self.PublicKey.MarshalText()
+ if err != nil {
+ log.Fatal("Failed to marshal public key: %s", err)
+ }
+
log.Info("Connected to Tailnet (PublicKey: %s)", status.Self.PublicKey)
- s.ID = string(status.Self.ID)
+ s.ID = string(id)
}
// Shuts down the tsnet agent.
diff --git a/agent/internal/util/logger.go b/agent/internal/util/logger.go
index 070e061..213b452 100644
--- a/agent/internal/util/logger.go
+++ b/agent/internal/util/logger.go
@@ -1,66 +1,117 @@
package util
import (
- "log"
+ "encoding/json"
+ "fmt"
"os"
+ "strings"
"sync"
+ "time"
)
-type Logger struct {
- debug *log.Logger
- info *log.Logger
- error *log.Logger
+type LogLevel string
+
+const (
+ LevelInfo LogLevel = "info"
+ LevelDebug LogLevel = "debug"
+ LevelError LogLevel = "error"
+ LevelFatal LogLevel = "fatal"
+ LevelMsg LogLevel = "msg"
+)
+
+type LogMessage struct {
+ Level LogLevel
+ Time string
+ Message any
}
-var lock = &sync.Mutex{}
-var logger *Logger
+type Logger struct {
+ debugEnabled bool
+ encoder *json.Encoder
+ pool *sync.Pool
+}
+
+var logger = NewLogger()
func GetLogger() *Logger {
- if logger == nil {
- lock.Lock()
- defer lock.Unlock()
- if logger == nil {
- logger = NewLogger()
- }
- }
-
return logger
}
func NewLogger() *Logger {
- // Create a new Logger for stdout and stderr
- // Errors still go to both stdout and stderr
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetEscapeHTML(false)
+
return &Logger{
- debug: nil,
- info: log.New(os.Stdout, "[INFO] ", log.LstdFlags),
- error: log.New(os.Stderr, "[ERROR] ", log.LstdFlags),
+ encoder: enc,
+ pool: &sync.Pool{
+ New: func() any {
+ return &LogMessage{}
+ },
+ },
}
}
-func (logger *Logger) SetDebug(debug bool) {
- if debug {
- logger.Info("Enabling Debug logging for headplane-agent")
- logger.Info("Be careful, this will spam a lot of information")
- logger.debug = log.New(os.Stdout, "[DEBUG] ", log.LstdFlags)
- } else {
- logger.debug = nil
+func (l *Logger) SetDebug(enabled bool) {
+ if enabled {
+ l.debugEnabled = true
+ l.Info("Enabling Debug logging for headplane-agent")
+ l.Info("Be careful, this will spam a lot of information")
}
}
-func (logger *Logger) Info(fmt string, v ...any) {
- logger.info.Printf(fmt, v...)
-}
+func (l *Logger) log(level LogLevel, format string, v ...any) {
+ msg := fmt.Sprintf(format, v...)
+ timestamp := time.Now().Format(time.RFC3339)
-func (logger *Logger) Debug(fmt string, v ...any) {
- if logger.debug != nil {
- logger.debug.Printf(fmt, v...)
+ // Manually construct compact JSON line for performance
+ line := `{"Level":"` + string(level) +
+ `","Time":"` + timestamp +
+ `","Message":"` + escapeString(msg) + `"}` + "\n"
+
+ if level == LevelError || level == LevelFatal {
+ os.Stderr.WriteString(line)
+ }
+
+ // Always write to stdout but also write to stderr for errors
+ os.Stdout.WriteString(line)
+ if level == LevelFatal {
+ os.Exit(1)
}
}
-func (logger *Logger) Error(fmt string, v ...any) {
- logger.error.Printf(fmt, v...)
+func (l *Logger) Debug(format string, v ...any) {
+ if l.debugEnabled {
+ l.log(LevelDebug, format, v...)
+ }
}
-func (logger *Logger) Fatal(fmt string, v ...any) {
- logger.error.Fatalf(fmt, v...)
+func (l *Logger) Info(format string, v ...any) { l.log(LevelInfo, format, v...) }
+func (l *Logger) Error(format string, v ...any) { l.log(LevelError, format, v...) }
+func (l *Logger) Fatal(format string, v ...any) { l.log(LevelFatal, format, v...) }
+
+func (l *Logger) Msg(obj any) {
+ entry := l.pool.Get().(*LogMessage)
+ defer l.pool.Put(entry)
+
+ entry.Level = LevelMsg
+ entry.Time = time.Now().Format(time.RFC3339)
+ entry.Message = obj
+
+ // Because the encoder is tied to STDOUT we get a message
+ _ = l.encoder.Encode(entry)
+
+ // Reset the entry for reuse
+ entry.Level = ""
+ entry.Time = ""
+ entry.Message = nil
+}
+
+func escapeString(s string) string {
+ replacer := strings.NewReplacer(
+ `"`, `\"`,
+ `\`, `\\`,
+ "\n", `\n`,
+ "\t", `\t`,
+ )
+ return replacer.Replace(s)
}
diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx
index 63b8923..e7a56b5 100644
--- a/app/routes/machines/overview.tsx
+++ b/app/routes/machines/overview.tsx
@@ -66,8 +66,10 @@ export async function loader({
magic,
server: context.config.headscale.url,
publicServer: context.config.headscale.public_url,
- agents: context.agents?.tailnetIDs(),
- stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
+ agent: context.agents?.agentID(),
+ stats: await context.agents?.lookup(
+ machines.nodes.map((node) => node.nodeKey),
+ ),
writable: writablePermission,
preAuth: await context.sessions.check(
request,
@@ -129,7 +131,7 @@ export default function Page() {
{/* We only want to show the version column if there are agents */}
- {data.agents !== undefined ? (
+ {data.agent !== undefined ? (
Version |
) : undefined}
Last Seen |
@@ -152,7 +154,7 @@ export default function Page() {
magic={data.magic}
// If we pass undefined, the column will not be rendered
// This is useful for when there are no agents configured
- isAgent={data.agents?.includes(machine.id)}
+ isAgent={data.agent === machine.nodeKey}
stats={data.stats?.[machine.nodeKey]}
isDisabled={
data.writable
diff --git a/app/server/config/schema.ts b/app/server/config/schema.ts
index e45a687..85497f4 100644
--- a/app/server/config/schema.ts
+++ b/app/server/config/schema.ts
@@ -1,22 +1,26 @@
import { type } from 'arktype';
-const stringToBool = type('string | boolean').pipe((v) => Boolean(v));
+const stringToBool = type('string | boolean').pipe((v) => {
+ if (typeof v === 'string') {
+ if (v === '1' || v === 'true' || v === 'yes') {
+ return true;
+ }
+
+ if (v === '0' || v === 'false' || v === 'no') {
+ return false;
+ }
+
+ throw new Error(`Invalid string value for boolean: ${v}`);
+ }
+
+ return Boolean(v);
+});
+
const serverConfig = type({
host: 'string.ip',
port: type('string | number.integer').pipe((v) => Number(v)),
cookie_secret: '32 <= string <= 32',
cookie_secure: stringToBool,
- agent: type({
- authkey: 'string = ""',
- ttl: 'number.integer = 180000', // Default to 3 minutes
- cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
- })
- .onDeepUndeclaredKey('reject')
- .default(() => ({
- authkey: '',
- ttl: 180000,
- cache_path: '/var/lib/headplane/agent_cache.json',
- })),
});
const oidcConfig = type({
@@ -46,6 +50,16 @@ const containerLabel = type({
value: 'string',
}).optional();
+const agentConfig = type({
+ enabled: stringToBool.default(false),
+ host_name: 'string = "headplane-agent"',
+ pre_authkey: 'string = ""',
+ cache_ttl: 'number.integer = 180000',
+ cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
+ executable_path: 'string = "/usr/libexec/headplane/agent"',
+ work_dir: 'string = "/var/lib/headplane/agent"',
+});
+
const dockerConfig = type({
enabled: stringToBool,
container_name: 'string',
@@ -67,12 +81,14 @@ const integrationConfig = type({
'docker?': dockerConfig,
'kubernetes?': kubernetesConfig,
'proc?': procConfig,
+ 'agent?': agentConfig,
}).onDeepUndeclaredKey('reject');
const partialIntegrationConfig = type({
'docker?': dockerConfig.partial(),
'kubernetes?': kubernetesConfig.partial(),
'proc?': procConfig.partial(),
+ 'agent?': agentConfig.partial(),
}).partial();
export const headplaneConfig = type({
diff --git a/app/server/index.ts b/app/server/index.ts
index df070bf..fbf4231 100644
--- a/app/server/index.ts
+++ b/app/server/index.ts
@@ -1,7 +1,6 @@
import { env, versions } from 'node:process';
-import type { UpgradeWebSocket } from 'hono/ws';
import { createHonoServer } from 'react-router-hono-server/node';
-import type { WebSocket } from 'ws';
+
import log from '~/utils/log';
import { configureConfig, configureLogger, envVariables } from './config/env';
import { loadIntegration } from './config/integration';
@@ -57,11 +56,9 @@ const appLoadContext = {
),
agents: await loadAgentSocket(
- config.server.agent.authkey,
- config.server.agent.cache_path,
- config.server.agent.ttl,
+ config.integration?.agent,
+ config.headscale.url,
),
-
integration: await loadIntegration(config.integration),
oidc: config.oidc ? await createOidcClient(config.oidc) : undefined,
};
@@ -71,7 +68,6 @@ declare module 'react-router' {
}
export default createHonoServer({
- useWebSocket: true,
overrideGlobalObjects: true,
port: config.server.port,
hostname: config.server.host,
@@ -85,20 +81,6 @@ export default createHonoServer({
return appLoadContext;
},
- configure(app, { upgradeWebSocket }) {
- const agentManager = appLoadContext.agents;
- if (agentManager) {
- app.get(
- `${__PREFIX__}/_dial`,
- // We need this since we cannot pass the WSEvents context
- // Also important to not pass the callback directly
- // since we need to retain `this` context
- (upgradeWebSocket as UpgradeWebSocket)((c) =>
- agentManager.configureSocket(c),
- ),
- );
- }
- },
listeningListener(info) {
log.info('server', 'Running on %s:%s', info.address, info.port);
},
diff --git a/app/server/web/agent.ts b/app/server/web/agent.ts
index 62c1f0c..311442f 100644
--- a/app/server/web/agent.ts
+++ b/app/server/web/agent.ts
@@ -1,159 +1,278 @@
+import { ChildProcess, spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
-import { open, readFile, writeFile } from 'node:fs/promises';
+import { constants, access, open, readFile, writeFile } from 'node:fs/promises';
+import { exit } from 'node:process';
+import { createInterface } from 'node:readline';
import { setTimeout } from 'node:timers/promises';
-import { getConnInfo } from '@hono/node-server/conninfo';
import { type } from 'arktype';
-import type { Context } from 'hono';
-import type { WSContext, WSEvents } from 'hono/ws';
-import { WebSocket } from 'ws';
import { HostInfo } from '~/types';
import log from '~/utils/log';
+import type { HeadplaneConfig } from '../config/schema';
+
+interface LogResponse {
+ Level: 'info' | 'debug' | 'error' | 'fatal';
+ Message: string;
+}
+
+interface RegisterMessage {
+ Type: 'register';
+ ID: string;
+}
+
+interface StatusMessage {
+ Type: 'status';
+ Data: Record;
+}
+
+interface MessageResponse {
+ Level: 'msg';
+ Message: RegisterMessage | StatusMessage;
+}
+
+type AgentResponse = LogResponse | MessageResponse;
export async function loadAgentSocket(
- authkey: string,
- path: string,
- ttl: number,
+ config: NonNullable['agent'] | undefined,
+ headscaleUrl: string,
) {
- if (authkey.length === 0) {
+ if (!config?.enabled) {
+ return;
+ }
+
+ if (config.pre_authkey.trim().length === 0) {
+ log.error('agent', 'Agent `pre_authkey` is not set');
+ log.warn('agent', 'The agent will not run until resolved');
return;
}
try {
- const handle = await open(path, 'w');
- log.info('agent', 'Using agent cache file at %s', path);
+ await access(config.work_dir, constants.R_OK | constants.W_OK);
+ log.debug('config', 'Using agent work dir at %s', config.work_dir);
+ } catch (error) {
+ log.info('config', 'Agent work dir not accessible at %s', config.work_dir);
+ log.debug('config', 'Error details: %s', error);
+ return;
+ }
+
+ try {
+ const handle = await open(config.cache_path, 'a+');
+ log.info('agent', 'Using agent cache file at %s', config.cache_path);
await handle.close();
} catch (error) {
- log.info('agent', 'Agent cache file not accessible at %s', path);
+ log.info(
+ 'agent',
+ 'Agent cache file not accessible at %s',
+ config.cache_path,
+ );
log.debug('agent', 'Error details: %s', error);
return;
}
- const cache = new TimedCache(ttl, path);
- return new AgentManager(cache, authkey);
+ const cache = new TimedCache(config.cache_ttl, config.cache_path);
+ return new AgentManager(cache, config, headscaleUrl);
}
class AgentManager {
+ private static readonly MAX_RESTARTS = 5;
+ private restartCounter = 0;
+
private cache: TimedCache;
- private agents: Map;
- private timers: Map;
- private authkey: string;
+ private headscaleUrl: string;
+ private config: NonNullable<
+ NonNullable['agent']
+ >;
- constructor(cache: TimedCache, authkey: string) {
+ private spawnProcess: ChildProcess | null;
+ private agentId: string | null;
+
+ constructor(
+ cache: TimedCache,
+ config: NonNullable['agent']>,
+ headscaleUrl: string,
+ ) {
this.cache = cache;
- this.authkey = authkey;
- this.agents = new Map();
- this.timers = new Map();
+ this.config = config;
+ this.headscaleUrl = headscaleUrl;
+ this.spawnProcess = null;
+ this.agentId = null;
+ this.startAgent();
+
+ process.on('SIGINT', () => {
+ this.spawnProcess?.kill();
+ exit(0);
+ });
+
+ process.on('SIGTERM', () => {
+ this.spawnProcess?.kill();
+ exit(0);
+ });
}
- tailnetIDs() {
- return Array.from(this.agents.keys());
+ /**
+ * Used by the UI to indicate why the agent is not running.
+ * Exhaustion requires a manual restart of the agent.
+ * (Which can be invoked via the UI)
+ * @returns true if the agent is exhausted
+ */
+ exhausted() {
+ return this.restartCounter >= AgentManager.MAX_RESTARTS;
}
- lookup(nodeIds: string[]) {
+ /*
+ * Called by the UI to manually force a restart of the agent.
+ */
+ deExhaust() {
+ this.restartCounter = 0;
+ this.startAgent();
+ }
+
+ /*
+ * Stored agent ID for the current process. This is caught by the agent
+ * when parsing the stdout on agent startup.
+ */
+ agentID() {
+ return this.agentId;
+ }
+
+ private startAgent() {
+ if (this.spawnProcess) {
+ log.debug('agent', 'Agent already running');
+ return;
+ }
+
+ if (this.exhausted()) {
+ log.error('agent', 'Agent is exhausted, cannot start');
+ return;
+ }
+
+ // Cannot be detached since we want to follow our process lifecycle
+ // We also need to be able to send data to the process by using stdin
+ log.info(
+ 'agent',
+ 'Starting agent process (attempt %d)',
+ this.restartCounter,
+ );
+ this.spawnProcess = spawn(this.config.executable_path, [], {
+ detached: false,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ env: {
+ HOME: process.env.HOME,
+ HEADPLANE_EMBEDDED: 'true',
+ HEADPLANE_AGENT_WORK_DIR: this.config.work_dir,
+ HEADPLANE_AGENT_DEBUG: log.debugEnabled ? 'true' : 'false',
+ HEADPLANE_AGENT_HOSTNAME: this.config.host_name,
+ HEADPLANE_AGENT_TS_SERVER: this.headscaleUrl,
+ HEADPLANE_AGENT_TS_AUTHKEY: this.config.pre_authkey,
+ },
+ });
+
+ if (!this.spawnProcess?.pid) {
+ log.error('agent', 'Failed to start agent process');
+ this.restartCounter++;
+ global.setTimeout(() => this.startAgent(), 1000);
+ return;
+ }
+
+ if (this.spawnProcess.stdin === null || this.spawnProcess.stdout === null) {
+ log.error('agent', 'Failed to connect to agent process');
+ this.restartCounter++;
+ global.setTimeout(() => this.startAgent(), 1000);
+ return;
+ }
+
+ const rlStdout = createInterface({
+ input: this.spawnProcess.stdout,
+ crlfDelay: Number.POSITIVE_INFINITY,
+ });
+
+ rlStdout.on('line', (line) => {
+ try {
+ const parsed = JSON.parse(line) as AgentResponse;
+ if (parsed.Level === 'msg') {
+ switch (parsed.Message.Type) {
+ case 'register':
+ this.agentId = parsed.Message.ID;
+ break;
+ case 'status':
+ for (const [key, value] of Object.entries(parsed.Message.Data)) {
+ // Mark the agent as the one that is running
+ // We store it in the cache so that it shows
+ // itself later
+ if (key === this.agentId) {
+ value.HeadplaneAgent = true;
+ }
+
+ this.cache.set(key, value);
+ }
+
+ break;
+ }
+
+ return;
+ }
+
+ switch (parsed.Level) {
+ case 'info':
+ case 'debug':
+ case 'error':
+ log[parsed.Level]('agent', parsed.Message);
+ break;
+ case 'fatal':
+ log.error('agent', parsed.Message);
+ break;
+ default:
+ log.debug('agent', 'Unknown agent response: %s', line);
+ break;
+ }
+ } catch (error) {
+ log.debug('agent', 'Failed to parse agent response: %s', error);
+ log.debug('agent', 'Raw data: %s', line);
+ }
+ });
+
+ this.spawnProcess.on('error', (error) => {
+ log.error('agent', 'Failed to start agent process: %s', error);
+ this.restartCounter++;
+ this.spawnProcess = null;
+ global.setTimeout(() => this.startAgent(), 1000);
+ });
+
+ this.spawnProcess.on('exit', (code) => {
+ log.error('agent', 'Agent process exited with code %d', code ?? -1);
+ this.restartCounter++;
+ this.spawnProcess = null;
+ global.setTimeout(() => this.startAgent(), 1000);
+ });
+ }
+
+ async lookup(nodeIds: string[]) {
const entries = this.cache.toJSON();
const missing = nodeIds.filter((nodeId) => !entries[nodeId]);
if (missing.length > 0) {
- this.requestData(missing);
+ await this.requestData(missing);
}
return entries;
}
- // Request data from all connected agents
- // This does not return anything, but caches the data which then needs to be
- // queried by the caller separately.
- private requestData(nodeList: string[]) {
- const NodeIDs = [...new Set(nodeList)];
- NodeIDs.map((node) => {
- log.debug('agent', 'Requesting agent data for %s', node);
- });
-
- for (const agent of this.agents.values()) {
- agent.send(JSON.stringify({ NodeIDs }));
+ // Request data from the internal agent by sending a message to the process
+ // via stdin. This is a blocking call, so it will wait for the agent to
+ // respond before returning.
+ private async requestData(nodeList: string[]) {
+ if (this.exhausted()) {
+ return;
}
- }
- // Since we are using Node, Hono is built on 'ws' WebSocket types.
- configureSocket(c: Context): WSEvents {
- return {
- onOpen: (_, ws) => {
- const id = c.req.header('x-headplane-tailnet-id');
- if (!id) {
- log.warn(
- 'agent',
- 'Rejecting an agent WebSocket connection without a tailnet ID',
- );
- ws.close(1008, 'ERR_INVALID_TAILNET_ID');
- return;
- }
+ // Wait for the process to be spawned, busy waiting is gross
+ while (this.spawnProcess === null) {
+ await setTimeout(100);
+ }
- const auth = c.req.header('authorization');
- if (auth !== `Bearer ${this.authkey}`) {
- log.warn('agent', 'Rejecting an unauthorized WebSocket connection');
-
- const info = getConnInfo(c);
- if (info.remote.address) {
- log.warn('agent', 'Agent source IP: %s', info.remote.address);
- }
-
- ws.close(1008, 'ERR_UNAUTHORIZED');
- return;
- }
-
- const pinger = setInterval(() => {
- if (ws.readyState !== 1) {
- clearInterval(pinger);
- return;
- }
-
- ws.raw?.ping();
- }, 30000);
-
- this.agents.set(id, ws);
- this.timers.set(id, pinger);
- },
-
- onClose: () => {
- const id = c.req.header('x-headplane-tailnet-id');
- if (!id) {
- return;
- }
-
- clearInterval(this.timers.get(id));
- this.agents.delete(id);
- },
-
- onError: (event, ws) => {
- const id = c.req.header('x-headplane-tailnet-id');
- if (!id) {
- return;
- }
-
- clearInterval(this.timers.get(id));
- if (event instanceof ErrorEvent) {
- log.error('agent', 'WebSocket error: %s', event.message);
- }
-
- log.debug('agent', 'Closing agent WebSocket connection');
- ws.close(1011, 'ERR_INTERNAL_ERROR');
- },
-
- // This is where we receive the data from the agent
- // Requests are made in the AgentManager.requestData function
- onMessage: (event, ws) => {
- const id = c.req.header('x-headplane-tailnet-id');
- if (!id) {
- return;
- }
-
- const data = JSON.parse(event.data.toString());
- log.debug('agent', 'Received agent data from %s', id);
- for (const [node, info] of Object.entries(data)) {
- this.cache.set(node, info);
- log.debug('agent', 'Cached HostInfo for %s', node);
- }
- },
- };
+ // Send the request to the agent, without waiting for a response.
+ // The live data invalidator will re-request the data if it is not
+ // available in the cache anyways.
+ const data = JSON.stringify({ NodeIDs: nodeList });
+ this.spawnProcess.stdin?.write(`${data}\n`);
}
}
diff --git a/app/types/HostInfo.ts b/app/types/HostInfo.ts
index 4cd10b7..3ebb404 100644
--- a/app/types/HostInfo.ts
+++ b/app/types/HostInfo.ts
@@ -3,6 +3,11 @@
// https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L816
export interface HostInfo {
+ /**
+ * Custom identifier we use to determine if its an agent or not
+ */
+ HeadplaneAgent?: boolean;
+
/** Version of this code (in version.Long format) */
IPNVersion?: string;
diff --git a/config.example.yaml b/config.example.yaml
index b8ffd29..e1adb4e 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -43,8 +43,34 @@ headscale:
config_strict: true
# Integration configurations for Headplane to interact with Headscale
-# Only one of these should be enabled at a time or you will get errors
integration:
+ # The Headplane agent allows retrieving information about nodes
+ # This allows the UI to display version, OS, and connectivity data
+ # You will see the Headplane agent in your Tailnet as a node when
+ # it connects.
+ enabled: false
+ # To connect to your Tailnet, you need to generate a pre-auth key
+ # This can be done via the web UI or through the `headscale` CLI.
+ pre_authkey: ""
+ # Optionally change the name of the agent in the Tailnet.
+ # host_name: "headplane-agent"
+
+ # Configure different caching settings. By default, the agent will store
+ # caches in the path below for a maximum of 1 minute. If you want data
+ # to update faster, reduce the TTL, but this will increase the frequency
+ # of requests to Headscale.
+ # cache_ttl: 60
+ # cache_path: /var/lib/headplane/agent_cache.json
+
+ # Do not change this unless you are running a custom deployment.
+ # The work_dir represents where the agent will store its data to be able
+ # to automatically reauthenticate with your Tailnet. It needs to be
+ # writable by the user running the Headplane process.
+ # work_dir: "/var/lib/headplane/agent"
+
+ # Only one of these should be enabled at a time or you will get errors
+ # This does not include the agent integration (above), which can be enabled
+ # at the same time as any of these and is recommended for the best experience.
docker:
enabled: false
# Preferred method: use container_label to dynamically discover the Headscale container.
diff --git a/package.json b/package.json
index 9eb9865..b510f07 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,6 @@
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/inter": "^5.1.1",
- "@hono/node-server": "^1.14.0",
"@kubernetes/client-node": "^0.22.3",
"@primer/octicons-react": "^19.14.0",
"@react-aria/toast": "3.0.0-beta.18",
@@ -43,13 +42,12 @@
"react-error-boundary": "^5.0.0",
"react-icons": "^5.5.0",
"react-router": "^7.4.0",
- "react-router-hono-server": "^2.11.0",
+ "react-router-hono-server": "^2.13.0",
"react-stately": "^3.35.0",
"remix-utils": "^8.0.0",
"tailwind-merge": "^2.6.0",
"undici": "^7.2.0",
"usehooks-ts": "^3.1.0",
- "ws": "^8.18.1",
"yaml": "^2.7.0",
"zod": "^3.24.1"
},
@@ -58,7 +56,6 @@
"@biomejs/biome": "^1.9.4",
"@react-router/dev": "^7.4.0",
"@types/websocket": "^1.0.10",
- "@types/ws": "^8.5.13",
"autoprefixer": "^10.4.21",
"babel-plugin-react-compiler": "19.0.0-beta-55955c9-20241229",
"lefthook": "^1.10.9",
diff --git a/patches/react-router-hono-server.patch b/patches/react-router-hono-server.patch
index 6b09840..96a847a 100644
--- a/patches/react-router-hono-server.patch
+++ b/patches/react-router-hono-server.patch
@@ -1,8 +1,8 @@
diff --git a/dist/adapters/node.d.ts b/dist/adapters/node.d.ts
-index 68742808892c1282ccff1e3321167862196d1229..f9a9249e1d1e573018d7ff3d3b967c4a1667d6ca 100644
+index 0108a13ac57e67858f2f91d05f22a4f81fc57508..dcbf308e17d8df6900e8b764d3e4d2e3e99deccc 100644
--- a/dist/adapters/node.d.ts
+++ b/dist/adapters/node.d.ts
-@@ -50,6 +50,10 @@ interface HonoNodeServerOptions extends HonoServerOpti
+@@ -51,6 +51,10 @@ interface HonoNodeServerOptions extends HonoServerOpti
/**
* Callback executed just after `serve` from `@hono/node-server`
*/
@@ -14,41 +14,35 @@ index 68742808892c1282ccff1e3321167862196d1229..f9a9249e1d1e573018d7ff3d3b967c4a
/**
* The Node.js Adapter rewrites the global Request/Response and uses a lightweight Request/Response to improve performance.
diff --git a/dist/adapters/node.js b/dist/adapters/node.js
-index 481dec801537f6ccf7f7a8a8e2294f4b0f20bb7d..980fecf219dd0c501ed415e36985ec56d997f14f 100644
+index 966604f94ca8528b684ef95fe7891c2e6352561b..56eb6650d00b047163377b9e9017b9c5f31b1fa9 100644
--- a/dist/adapters/node.js
+++ b/dist/adapters/node.js
-@@ -46,16 +46,22 @@ async function createHonoServer(options) {
+@@ -46,16 +46,24 @@ async function createHonoServer(options) {
}
await mergedOptions.beforeAll?.(app);
app.use(
- `/${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`,
-+ `${__PREFIX__}/${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`,
++ `/${__PREFIX__}${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`,
cache(60 * 60 * 24 * 365),
// 1 year
-- serveStatic({ root: clientBuildPath })
+- serveStatic({ root: clientBuildPath, ...mergedOptions.serveStaticOptions?.clientAssets })
+ serveStatic({
-+ root: clientBuildPath,
-+ rewriteRequestPath: path => path.replace(__PREFIX__, "/")
-+ })
++ root: clientBuildPath,
++ ...mergedOptions.serveStaticOptions?.clientAssets,
++ rewriteRequestPath: path => path.replace(__PREFIX__, "/")
++ })
);
app.use(
- "*",
-+ `${__PREFIX__}/assets/*`,
++ `${__PREFIX__}/*`,
cache(60 * 60),
// 1 hour
-- serveStatic({ root: PRODUCTION ? clientBuildPath : "./public" })
+- serveStatic({ root: PRODUCTION ? clientBuildPath : "./public", ...mergedOptions.serveStaticOptions?.publicAssets })
+ serveStatic({
-+ root: PRODUCTION ? clientBuildPath : "./public",
-+ rewriteRequestPath: path => path.replace(__PREFIX__, "/")
-+ })
++ root: PRODUCTION ? clientBuildPath : "./public",
++ ...mergedOptions.serveStaticOptions?.publicAssets,
++ rewriteRequestPath: path => path.replace(__PREFIX__, "/")
++ })
);
if (mergedOptions.defaultLogger) {
app.use("*", logger());
-@@ -86,6 +92,7 @@ async function createHonoServer(options) {
- ...app,
- ...mergedOptions.customNodeServer,
- port: mergedOptions.port,
-+ hostname: mergedOptions.hostname,
- overrideGlobalObjects: mergedOptions.overrideGlobalObjects
- },
- mergedOptions.listeningListener
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f4ca1fe..053c936 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,7 +9,7 @@ patchedDependencies:
hash: 915164bae9a5d47bb0e7edf0cbbc4c7f0fedb1a2f9a5f6ef5c53d8fef6856211
path: patches/@shopify__lang-jsonc@1.0.0.patch
react-router-hono-server:
- hash: c547fd5480e282b40f1c4e668ab066a78abdde8fe04871a65bda306896b0a39e
+ hash: 99b0b196753142b8bd30e3d98d42462fbea5d06cebfd17d820b073e7ec51e6c3
path: patches/react-router-hono-server.patch
importers:
@@ -31,9 +31,6 @@ importers:
'@fontsource-variable/inter':
specifier: ^5.1.1
version: 5.1.1
- '@hono/node-server':
- specifier: ^1.14.0
- version: 1.14.0(hono@4.7.5)
'@kubernetes/client-node':
specifier: ^0.22.3
version: 0.22.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)
@@ -110,8 +107,8 @@ importers:
specifier: ^7.4.0
version: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react-router-hono-server:
- specifier: ^2.11.0
- version: 2.11.0(patch_hash=c547fd5480e282b40f1c4e668ab066a78abdde8fe04871a65bda306896b0a39e)(@react-router/dev@7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0))(@types/react@19.0.2)(bufferutil@4.0.9)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(utf-8-validate@5.0.10)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))
+ specifier: ^2.13.0
+ version: 2.13.0(patch_hash=99b0b196753142b8bd30e3d98d42462fbea5d06cebfd17d820b073e7ec51e6c3)(@react-router/dev@7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0))(@types/react@19.0.2)(bufferutil@4.0.9)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(utf-8-validate@5.0.10)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))
react-stately:
specifier: ^3.35.0
version: 3.35.0(react@19.0.0)
@@ -127,9 +124,6 @@ importers:
usehooks-ts:
specifier: ^3.1.0
version: 3.1.0(react@19.0.0)
- ws:
- specifier: ^8.18.1
- version: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
yaml:
specifier: ^2.7.0
version: 2.7.0
@@ -149,9 +143,6 @@ importers:
'@types/websocket':
specifier: ^1.0.10
version: 1.0.10
- '@types/ws':
- specifier: ^8.5.13
- version: 8.5.13
autoprefixer:
specifier: ^10.4.21
version: 10.4.21(postcss@8.5.3)
@@ -457,8 +448,8 @@ packages:
'@codemirror/commands@6.7.1':
resolution: {integrity: sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==}
- '@codemirror/commands@6.8.0':
- resolution: {integrity: sha512-q8VPEFaEP4ikSlt6ZxjB3zW72+7osfAYW9i8Zu943uqbKuz6utc1+F170hyLUCUltXORjQXRyYQNfkckzA/bPQ==}
+ '@codemirror/commands@6.8.1':
+ resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
'@codemirror/language@6.10.8':
resolution: {integrity: sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw==}
@@ -980,15 +971,15 @@ packages:
peerDependencies:
hono: ^4
- '@hono/node-ws@1.1.0':
- resolution: {integrity: sha512-uHaz1EPguJqsUmA+Jmhdi/DTRAMs2Fvcy7qno9E48rlK3WBtyGQw4u4DKlc+o18Nh1DGz2oA1n9hCzEyhVBeLw==}
+ '@hono/node-ws@1.1.1':
+ resolution: {integrity: sha512-iFJrAw5GuBTstehBzLY2FyW5rRlXmO3Uwpijpm4Liv75owNP/UjZe3KExsLuEK4w+u+xhvHqOoQUyEKWUvyghw==}
engines: {node: '>=18.14.1'}
peerDependencies:
'@hono/node-server': ^1.11.1
hono: ^4.6.0
- '@hono/vite-dev-server@0.17.0':
- resolution: {integrity: sha512-EvGOIj1MoY9uV94onXXz88yWaTxzUK+Mv8LiIEsR/9eSFoVUnHVR0B7l7iNIsxfHYRN7tbPDMWBSnD2RQun3yw==}
+ '@hono/vite-dev-server@0.19.0':
+ resolution: {integrity: sha512-myMc4Nm0nFQSPaeE6I/a1ODyDR5KpQ4EHodX4Tu/7qlB31GfUemhUH/WsO91HJjDEpRRpsT4Zbg+PleMlpTljw==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: '*'
@@ -1826,9 +1817,6 @@ packages:
'@types/node@20.17.16':
resolution: {integrity: sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==}
- '@types/node@22.10.1':
- resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==}
-
'@types/node@22.10.7':
resolution: {integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==}
@@ -1843,9 +1831,6 @@ packages:
'@types/websocket@1.0.10':
resolution: {integrity: sha512-svjGZvPB7EzuYS94cI7a+qhwgGU1y89wUgjT6E2wVUfmAGIvRfT7obBvRtnhXCSsoMdlG4gBFGE7MfkIXZLoww==}
- '@types/ws@8.5.13':
- resolution: {integrity: sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==}
-
'@uiw/codemirror-extensions-basic-setup@4.23.7':
resolution: {integrity: sha512-9/2EUa1Lck4kFKkR2BkxlZPpgD/EWuKHnOlysf1yHKZGraaZmZEaUw+utDK4QcuJc8Iz097vsLz4f4th5EU27g==}
peerDependencies:
@@ -2270,8 +2255,8 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
- hono@4.7.5:
- resolution: {integrity: sha512-fDOK5W2C1vZACsgLONigdZTRZxuBqFtcKh7bUQ5cVSbwI2RWjloJDcgFOVzbQrlI6pCmhlTsVYZ7zpLj4m4qMQ==}
+ hono@4.7.6:
+ resolution: {integrity: sha512-564rVzELU+9BRqqx5k8sT2NFwGD3I3Vifdb6P7CmM6FiarOSY+fDC+6B+k9wcCb86ReoayteZP2ki0cRLN1jbw==}
engines: {node: '>=16.9.0'}
hosted-git-info@6.1.3:
@@ -2755,18 +2740,18 @@ packages:
react: '>=18'
react-dom: '>=18'
- react-router-hono-server@2.11.0:
- resolution: {integrity: sha512-zn0kJUUamgxYS7mMDLv0kHCJE1UTX0bYNdfJeBLjw0xr/gnre0ttEZ2LTsFM8re1P2iMQ64mftpnSyeXIPijOA==}
+ react-router-hono-server@2.13.0:
+ resolution: {integrity: sha512-YcxmFpphZL9Jc4CnOgsKw35wcurmiOpTg3vBzjOROIUhEo6rKvUHOg7AM2QJi2Fa8BRJK6MvHaN4haedYo1iiA==}
engines: {node: '>=22.12.0'}
hasBin: true
peerDependencies:
- '@cloudflare/workers-types': ^4.20241112.0
+ '@cloudflare/workers-types': ^4.20250317.0
'@react-router/dev': ^7.2.0
'@types/react': ^18.3.10 || ^19.0.0
miniflare: ^3.20241205.0
react-router: ^7.2.0
- vite: ^5.1.0 || ^6.0.0
- wrangler: ^3.91.0
+ vite: ^6.0.0
+ wrangler: ^4.2.0
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
@@ -3679,7 +3664,7 @@ snapshots:
'@codemirror/view': 6.36.1
'@lezer/common': 1.2.3
- '@codemirror/commands@6.8.0':
+ '@codemirror/commands@6.8.1':
dependencies:
'@codemirror/language': 6.10.8
'@codemirror/state': 6.5.0
@@ -4015,23 +4000,23 @@ snapshots:
dependencies:
tslib: 2.8.1
- '@hono/node-server@1.14.0(hono@4.7.5)':
+ '@hono/node-server@1.14.0(hono@4.7.6)':
dependencies:
- hono: 4.7.5
+ hono: 4.7.6
- '@hono/node-ws@1.1.0(@hono/node-server@1.14.0(hono@4.7.5))(bufferutil@4.0.9)(hono@4.7.5)(utf-8-validate@5.0.10)':
+ '@hono/node-ws@1.1.1(@hono/node-server@1.14.0(hono@4.7.6))(bufferutil@4.0.9)(hono@4.7.6)(utf-8-validate@5.0.10)':
dependencies:
- '@hono/node-server': 1.14.0(hono@4.7.5)
- hono: 4.7.5
+ '@hono/node-server': 1.14.0(hono@4.7.6)
+ hono: 4.7.6
ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
transitivePeerDependencies:
- bufferutil
- utf-8-validate
- '@hono/vite-dev-server@0.17.0(hono@4.7.5)':
+ '@hono/vite-dev-server@0.19.0(hono@4.7.6)':
dependencies:
- '@hono/node-server': 1.14.0(hono@4.7.5)
- hono: 4.7.5
+ '@hono/node-server': 1.14.0(hono@4.7.6)
+ hono: 4.7.6
minimatch: 9.0.5
'@internationalized/date@3.6.0':
@@ -5325,10 +5310,6 @@ snapshots:
dependencies:
undici-types: 6.19.8
- '@types/node@22.10.1':
- dependencies:
- undici-types: 6.20.0
-
'@types/node@22.10.7':
dependencies:
undici-types: 6.20.0
@@ -5345,10 +5326,6 @@ snapshots:
dependencies:
'@types/node': 22.10.7
- '@types/ws@8.5.13':
- dependencies:
- '@types/node': 22.10.1
-
'@uiw/codemirror-extensions-basic-setup@4.23.7(@codemirror/autocomplete@6.18.2(@codemirror/language@6.10.8)(@codemirror/state@6.5.0)(@codemirror/view@6.36.1)(@lezer/common@1.2.3))(@codemirror/commands@6.7.1)(@codemirror/language@6.10.8)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.5.0)(@codemirror/view@6.36.1)':
dependencies:
'@codemirror/autocomplete': 6.18.2(@codemirror/language@6.10.8)(@codemirror/state@6.5.0)(@codemirror/view@6.36.1)(@lezer/common@1.2.3)
@@ -5552,7 +5529,7 @@ snapshots:
codemirror@6.0.1(@lezer/common@1.2.3):
dependencies:
'@codemirror/autocomplete': 6.18.2(@codemirror/language@6.10.8)(@codemirror/state@6.5.0)(@codemirror/view@6.36.1)(@lezer/common@1.2.3)
- '@codemirror/commands': 6.8.0
+ '@codemirror/commands': 6.8.1
'@codemirror/language': 6.10.8
'@codemirror/lint': 6.8.2
'@codemirror/search': 6.5.7
@@ -5821,7 +5798,7 @@ snapshots:
dependencies:
function-bind: 1.1.2
- hono@4.7.5: {}
+ hono@4.7.6: {}
hosted-git-info@6.1.3:
dependencies:
@@ -6255,15 +6232,15 @@ snapshots:
react-dom: 19.0.0(react@19.0.0)
react-router: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
- react-router-hono-server@2.11.0(patch_hash=c547fd5480e282b40f1c4e668ab066a78abdde8fe04871a65bda306896b0a39e)(@react-router/dev@7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0))(@types/react@19.0.2)(bufferutil@4.0.9)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(utf-8-validate@5.0.10)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)):
+ react-router-hono-server@2.13.0(patch_hash=99b0b196753142b8bd30e3d98d42462fbea5d06cebfd17d820b073e7ec51e6c3)(@react-router/dev@7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0))(@types/react@19.0.2)(bufferutil@4.0.9)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(utf-8-validate@5.0.10)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)):
dependencies:
'@drizzle-team/brocli': 0.11.0
- '@hono/node-server': 1.14.0(hono@4.7.5)
- '@hono/node-ws': 1.1.0(@hono/node-server@1.14.0(hono@4.7.5))(bufferutil@4.0.9)(hono@4.7.5)(utf-8-validate@5.0.10)
- '@hono/vite-dev-server': 0.17.0(hono@4.7.5)
+ '@hono/node-server': 1.14.0(hono@4.7.6)
+ '@hono/node-ws': 1.1.1(@hono/node-server@1.14.0(hono@4.7.6))(bufferutil@4.0.9)(hono@4.7.6)(utf-8-validate@5.0.10)
+ '@hono/vite-dev-server': 0.19.0(hono@4.7.6)
'@react-router/dev': 7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0)
'@types/react': 19.0.2
- hono: 4.7.5
+ hono: 4.7.6
react-router: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
vite: 6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)
transitivePeerDependencies: