feat: switch away from websocket to stdout messaging for agent

This commit is contained in:
Aarnav Tale 2025-04-11 12:00:19 -04:00
parent b090354d50
commit 9a8546ef09
No known key found for this signature in database
19 changed files with 522 additions and 420 deletions

1
.npmrc
View File

@ -1,2 +1 @@
side-effects-cache = false side-effects-cache = false
public-hoist-pattern[]=*hono*

View File

@ -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 FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
@ -11,8 +23,12 @@ COPY . .
RUN pnpm run build RUN pnpm run build
FROM node:22-alpine FROM node:22-alpine
RUN apk add --no-cache ca-certificates
RUN mkdir -p /var/lib/headplane RUN mkdir -p /var/lib/headplane
RUN mkdir -p /usr/libexec/headplane
WORKDIR /app WORKDIR /app
COPY --from=build /app/build /app/build 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" ] CMD [ "node", "./build/server/index.js" ]

View File

@ -8,6 +8,11 @@ import (
"github.com/tale/headplane/agent/internal/util" "github.com/tale/headplane/agent/internal/util"
) )
type Register struct {
Type string
ID string
}
func main() { func main() {
log := util.GetLogger() log := util.GetLogger()
cfg, err := config.Load() cfg, err := config.Load()
@ -21,11 +26,10 @@ func main() {
agent.Connect() agent.Connect()
defer agent.Shutdown() defer agent.Shutdown()
ws, err := hpagent.NewSocket(agent, cfg) log.Msg(&Register{
if err != nil { Type: "register",
log.Fatal("Failed to create websocket: %s", err) ID: agent.ID,
} })
defer ws.StopListening() hpagent.FollowMaster(agent)
ws.FollowMaster()
} }

View File

@ -8,8 +8,7 @@ type Config struct {
Hostname string Hostname string
TSControlURL string TSControlURL string
TSAuthKey string TSAuthKey string
HPControlURL string WorkDir string
HPAuthKey string
} }
const ( const (
@ -17,8 +16,7 @@ const (
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME" HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER" TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY" TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
HPControlURLEnv = "HEADPLANE_AGENT_HP_SERVER" WorkDirEnv = "HEADPLANE_AGENT_WORK_DIR"
HPAuthKeyEnv = "HEADPLANE_AGENT_HP_AUTHKEY"
) )
// Load reads the agent configuration from environment variables. // Load reads the agent configuration from environment variables.
@ -28,8 +26,7 @@ func Load() (*Config, error) {
Hostname: os.Getenv(HostnameEnv), Hostname: os.Getenv(HostnameEnv),
TSControlURL: os.Getenv(TSControlURLEnv), TSControlURL: os.Getenv(TSControlURLEnv),
TSAuthKey: os.Getenv(TSAuthKeyEnv), TSAuthKey: os.Getenv(TSAuthKeyEnv),
HPControlURL: os.Getenv(HPControlURLEnv), WorkDir: os.Getenv(WorkDirEnv),
HPAuthKey: os.Getenv(HPAuthKeyEnv),
} }
if os.Getenv(DebugEnv) == "true" { if os.Getenv(DebugEnv) == "true" {
@ -44,9 +41,5 @@ func Load() (*Config, error) {
return nil, err return nil, err
} }
if err := validateHPReady(c); err != nil {
return nil, err
}
return c, nil return c, nil
} }

View File

@ -16,16 +16,12 @@ func validateRequired(config *Config) error {
return fmt.Errorf("%s is required", TSControlURLEnv) return fmt.Errorf("%s is required", TSControlURLEnv)
} }
if config.HPControlURL == "" {
return fmt.Errorf("%s is required", HPControlURLEnv)
}
if config.TSAuthKey == "" { if config.TSAuthKey == "" {
return fmt.Errorf("%s is required", TSAuthKeyEnv) return fmt.Errorf("%s is required", TSAuthKeyEnv)
} }
if config.HPAuthKey == "" { if config.WorkDir == "" {
return fmt.Errorf("%s is required", HPAuthKeyEnv) return fmt.Errorf("%s is required", WorkDirEnv)
} }
return nil return nil
@ -50,23 +46,3 @@ func validateTSReady(config *Config) error {
return nil 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
}

View File

@ -1,9 +1,12 @@
package hpagent package hpagent
import ( import (
"bufio"
"encoding/json" "encoding/json"
"os"
"sync" "sync"
"github.com/tale/headplane/agent/internal/tsnet"
"github.com/tale/headplane/agent/internal/util" "github.com/tale/headplane/agent/internal/util"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
) )
@ -13,30 +16,32 @@ type RecvMessage struct {
NodeIDs []string NodeIDs []string
} }
// Starts listening for messages from the Headplane master type SendMessage struct {
func (s *Socket) FollowMaster() { Type string
log := util.GetLogger() Data any
}
for { // Starts listening for messages from stdin
_, message, err := s.ReadMessage() func FollowMaster(agent *tsnet.TSAgent) {
if err != nil { log := util.GetLogger()
log.Error("Error reading message: %s", err) scanner := bufio.NewScanner(os.Stdin)
return
} for scanner.Scan() {
line := scanner.Bytes()
var msg RecvMessage var msg RecvMessage
err = json.Unmarshal(message, &msg) err := json.Unmarshal(line, &msg)
if err != nil { if err != nil {
log.Error("Unable to unmarshal message: %s", err) log.Error("Unable to unmarshal message: %s", err)
log.Debug("Full Error: %v", err) log.Debug("Full Error: %v", err)
continue continue
} }
log.Debug("Recieved message from master: %v", message) log.Debug("Recieved message from master: %v", line)
if len(msg.NodeIDs) == 0 { if len(msg.NodeIDs) == 0 {
log.Debug("Message recieved had no node IDs") log.Debug("Message recieved had no node IDs")
log.Debug("Full message: %s", message) log.Debug("Full message: %s", line)
continue continue
} }
@ -49,7 +54,7 @@ func (s *Socket) FollowMaster() {
wg.Add(1) wg.Add(1)
go func(nodeID string) { go func(nodeID string) {
defer wg.Done() defer wg.Done()
result, err := s.Agent.GetStatusForPeer(nodeID) result, err := agent.GetStatusForPeer(nodeID)
if err != nil { if err != nil {
log.Error("Unable to get status for node %s: %s", nodeID, err) log.Error("Unable to get status for node %s: %s", nodeID, err)
return return
@ -70,15 +75,13 @@ func (s *Socket) FollowMaster() {
// Send the results back to the Headplane master // Send the results back to the Headplane master
log.Debug("Sending status back to master: %v", results) log.Debug("Sending status back to master: %v", results)
err = s.SendStatus(results) log.Msg(&SendMessage{
if err != nil { Type: "status",
log.Error("Error sending status: %s", err) Data: results,
return })
} }
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()
}

View File

@ -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
}

View File

@ -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
}

View File

@ -2,6 +2,9 @@ package tsnet
import ( import (
"context" "context"
"os"
"path/filepath"
"github.com/tale/headplane/agent/internal/config" "github.com/tale/headplane/agent/internal/config"
"github.com/tale/headplane/agent/internal/util" "github.com/tale/headplane/agent/internal/util"
"tailscale.com/client/tailscale" "tailscale.com/client/tailscale"
@ -17,15 +20,27 @@ type TSAgent struct {
// Creates a new tsnet agent and returns an instance of the server. // Creates a new tsnet agent and returns an instance of the server.
func NewAgent(cfg *config.Config) *TSAgent { 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{ server := &tsnet.Server{
Dir: dir,
Hostname: cfg.Hostname, Hostname: cfg.Hostname,
ControlURL: cfg.TSControlURL, ControlURL: cfg.TSControlURL,
AuthKey: cfg.TSAuthKey, AuthKey: cfg.TSAuthKey,
Logf: func(string, ...interface{}) {}, // Disabled by default Logf: func(string, ...any) {}, // Disabled by default
UserLogf: log.Info,
} }
if cfg.Debug { if cfg.Debug {
log := util.GetLogger()
server.Logf = log.Debug server.Logf = log.Debug
} }
@ -47,8 +62,13 @@ func (s *TSAgent) Connect() {
log.Fatal("Failed to initialize local Tailscale client: %s", err) 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) 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. // Shuts down the tsnet agent.

View File

@ -1,66 +1,117 @@
package util package util
import ( import (
"log" "encoding/json"
"fmt"
"os" "os"
"strings"
"sync" "sync"
"time"
) )
type Logger struct { type LogLevel string
debug *log.Logger
info *log.Logger const (
error *log.Logger 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{} type Logger struct {
var logger *Logger debugEnabled bool
encoder *json.Encoder
pool *sync.Pool
}
var logger = NewLogger()
func GetLogger() *Logger { func GetLogger() *Logger {
if logger == nil {
lock.Lock()
defer lock.Unlock()
if logger == nil {
logger = NewLogger()
}
}
return logger return logger
} }
func NewLogger() *Logger { func NewLogger() *Logger {
// Create a new Logger for stdout and stderr enc := json.NewEncoder(os.Stdout)
// Errors still go to both stdout and stderr enc.SetEscapeHTML(false)
return &Logger{ return &Logger{
debug: nil, encoder: enc,
info: log.New(os.Stdout, "[INFO] ", log.LstdFlags), pool: &sync.Pool{
error: log.New(os.Stderr, "[ERROR] ", log.LstdFlags), New: func() any {
return &LogMessage{}
},
},
} }
} }
func (logger *Logger) SetDebug(debug bool) { func (l *Logger) SetDebug(enabled bool) {
if debug { if enabled {
logger.Info("Enabling Debug logging for headplane-agent") l.debugEnabled = true
logger.Info("Be careful, this will spam a lot of information") l.Info("Enabling Debug logging for headplane-agent")
logger.debug = log.New(os.Stdout, "[DEBUG] ", log.LstdFlags) l.Info("Be careful, this will spam a lot of information")
} else {
logger.debug = nil
} }
} }
func (logger *Logger) Info(fmt string, v ...any) { func (l *Logger) log(level LogLevel, format string, v ...any) {
logger.info.Printf(fmt, v...) msg := fmt.Sprintf(format, v...)
} timestamp := time.Now().Format(time.RFC3339)
func (logger *Logger) Debug(fmt string, v ...any) { // Manually construct compact JSON line for performance
if logger.debug != nil { line := `{"Level":"` + string(level) +
logger.debug.Printf(fmt, v...) `","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) { func (l *Logger) Debug(format string, v ...any) {
logger.error.Printf(fmt, v...) if l.debugEnabled {
l.log(LevelDebug, format, v...)
}
} }
func (logger *Logger) Fatal(fmt string, v ...any) { func (l *Logger) Info(format string, v ...any) { l.log(LevelInfo, format, v...) }
logger.error.Fatalf(fmt, 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)
} }

View File

@ -66,8 +66,10 @@ export async function loader({
magic, magic,
server: context.config.headscale.url, server: context.config.headscale.url,
publicServer: context.config.headscale.public_url, publicServer: context.config.headscale.public_url,
agents: context.agents?.tailnetIDs(), agent: context.agents?.agentID(),
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)), stats: await context.agents?.lookup(
machines.nodes.map((node) => node.nodeKey),
),
writable: writablePermission, writable: writablePermission,
preAuth: await context.sessions.check( preAuth: await context.sessions.check(
request, request,
@ -129,7 +131,7 @@ export default function Page() {
</div> </div>
</th> </th>
{/* We only want to show the version column if there are agents */} {/* We only want to show the version column if there are agents */}
{data.agents !== undefined ? ( {data.agent !== undefined ? (
<th className="uppercase text-xs font-bold pb-2">Version</th> <th className="uppercase text-xs font-bold pb-2">Version</th>
) : undefined} ) : undefined}
<th className="uppercase text-xs font-bold pb-2">Last Seen</th> <th className="uppercase text-xs font-bold pb-2">Last Seen</th>
@ -152,7 +154,7 @@ export default function Page() {
magic={data.magic} magic={data.magic}
// If we pass undefined, the column will not be rendered // If we pass undefined, the column will not be rendered
// This is useful for when there are no agents configured // 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]} stats={data.stats?.[machine.nodeKey]}
isDisabled={ isDisabled={
data.writable data.writable

View File

@ -1,22 +1,26 @@
import { type } from 'arktype'; 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({ const serverConfig = type({
host: 'string.ip', host: 'string.ip',
port: type('string | number.integer').pipe((v) => Number(v)), port: type('string | number.integer').pipe((v) => Number(v)),
cookie_secret: '32 <= string <= 32', cookie_secret: '32 <= string <= 32',
cookie_secure: stringToBool, 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({ const oidcConfig = type({
@ -46,6 +50,16 @@ const containerLabel = type({
value: 'string', value: 'string',
}).optional(); }).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({ const dockerConfig = type({
enabled: stringToBool, enabled: stringToBool,
container_name: 'string', container_name: 'string',
@ -67,12 +81,14 @@ const integrationConfig = type({
'docker?': dockerConfig, 'docker?': dockerConfig,
'kubernetes?': kubernetesConfig, 'kubernetes?': kubernetesConfig,
'proc?': procConfig, 'proc?': procConfig,
'agent?': agentConfig,
}).onDeepUndeclaredKey('reject'); }).onDeepUndeclaredKey('reject');
const partialIntegrationConfig = type({ const partialIntegrationConfig = type({
'docker?': dockerConfig.partial(), 'docker?': dockerConfig.partial(),
'kubernetes?': kubernetesConfig.partial(), 'kubernetes?': kubernetesConfig.partial(),
'proc?': procConfig.partial(), 'proc?': procConfig.partial(),
'agent?': agentConfig.partial(),
}).partial(); }).partial();
export const headplaneConfig = type({ export const headplaneConfig = type({

View File

@ -1,7 +1,6 @@
import { env, versions } from 'node:process'; import { env, versions } from 'node:process';
import type { UpgradeWebSocket } from 'hono/ws';
import { createHonoServer } from 'react-router-hono-server/node'; import { createHonoServer } from 'react-router-hono-server/node';
import type { WebSocket } from 'ws';
import log from '~/utils/log'; import log from '~/utils/log';
import { configureConfig, configureLogger, envVariables } from './config/env'; import { configureConfig, configureLogger, envVariables } from './config/env';
import { loadIntegration } from './config/integration'; import { loadIntegration } from './config/integration';
@ -57,11 +56,9 @@ const appLoadContext = {
), ),
agents: await loadAgentSocket( agents: await loadAgentSocket(
config.server.agent.authkey, config.integration?.agent,
config.server.agent.cache_path, config.headscale.url,
config.server.agent.ttl,
), ),
integration: await loadIntegration(config.integration), integration: await loadIntegration(config.integration),
oidc: config.oidc ? await createOidcClient(config.oidc) : undefined, oidc: config.oidc ? await createOidcClient(config.oidc) : undefined,
}; };
@ -71,7 +68,6 @@ declare module 'react-router' {
} }
export default createHonoServer({ export default createHonoServer({
useWebSocket: true,
overrideGlobalObjects: true, overrideGlobalObjects: true,
port: config.server.port, port: config.server.port,
hostname: config.server.host, hostname: config.server.host,
@ -85,20 +81,6 @@ export default createHonoServer({
return appLoadContext; 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<WebSocket>)((c) =>
agentManager.configureSocket(c),
),
);
}
},
listeningListener(info) { listeningListener(info) {
log.info('server', 'Running on %s:%s', info.address, info.port); log.info('server', 'Running on %s:%s', info.address, info.port);
}, },

View File

@ -1,159 +1,278 @@
import { ChildProcess, spawn } from 'node:child_process';
import { createHash } from 'node:crypto'; 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 { setTimeout } from 'node:timers/promises';
import { getConnInfo } from '@hono/node-server/conninfo';
import { type } from 'arktype'; 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 { HostInfo } from '~/types';
import log from '~/utils/log'; 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<string, HostInfo>;
}
interface MessageResponse {
Level: 'msg';
Message: RegisterMessage | StatusMessage;
}
type AgentResponse = LogResponse | MessageResponse;
export async function loadAgentSocket( export async function loadAgentSocket(
authkey: string, config: NonNullable<HeadplaneConfig['integration']>['agent'] | undefined,
path: string, headscaleUrl: string,
ttl: number,
) { ) {
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; return;
} }
try { try {
const handle = await open(path, 'w'); await access(config.work_dir, constants.R_OK | constants.W_OK);
log.info('agent', 'Using agent cache file at %s', path); 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(); await handle.close();
} catch (error) { } 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); log.debug('agent', 'Error details: %s', error);
return; return;
} }
const cache = new TimedCache<HostInfo>(ttl, path); const cache = new TimedCache<HostInfo>(config.cache_ttl, config.cache_path);
return new AgentManager(cache, authkey); return new AgentManager(cache, config, headscaleUrl);
} }
class AgentManager { class AgentManager {
private static readonly MAX_RESTARTS = 5;
private restartCounter = 0;
private cache: TimedCache<HostInfo>; private cache: TimedCache<HostInfo>;
private agents: Map<string, WSContext>; private headscaleUrl: string;
private timers: Map<string, NodeJS.Timeout>; private config: NonNullable<
private authkey: string; NonNullable<HeadplaneConfig['integration']>['agent']
>;
constructor(cache: TimedCache<HostInfo>, authkey: string) { private spawnProcess: ChildProcess | null;
private agentId: string | null;
constructor(
cache: TimedCache<HostInfo>,
config: NonNullable<NonNullable<HeadplaneConfig['integration']>['agent']>,
headscaleUrl: string,
) {
this.cache = cache; this.cache = cache;
this.authkey = authkey; this.config = config;
this.agents = new Map(); this.headscaleUrl = headscaleUrl;
this.timers = new Map(); 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 entries = this.cache.toJSON();
const missing = nodeIds.filter((nodeId) => !entries[nodeId]); const missing = nodeIds.filter((nodeId) => !entries[nodeId]);
if (missing.length > 0) { if (missing.length > 0) {
this.requestData(missing); await this.requestData(missing);
} }
return entries; return entries;
} }
// Request data from all connected agents // Request data from the internal agent by sending a message to the process
// This does not return anything, but caches the data which then needs to be // via stdin. This is a blocking call, so it will wait for the agent to
// queried by the caller separately. // respond before returning.
private requestData(nodeList: string[]) { private async requestData(nodeList: string[]) {
const NodeIDs = [...new Set(nodeList)]; if (this.exhausted()) {
NodeIDs.map((node) => { return;
log.debug('agent', 'Requesting agent data for %s', node);
});
for (const agent of this.agents.values()) {
agent.send(JSON.stringify({ NodeIDs }));
} }
}
// Since we are using Node, Hono is built on 'ws' WebSocket types. // Wait for the process to be spawned, busy waiting is gross
configureSocket(c: Context): WSEvents<WebSocket> { while (this.spawnProcess === null) {
return { await setTimeout(100);
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;
}
const auth = c.req.header('authorization'); // Send the request to the agent, without waiting for a response.
if (auth !== `Bearer ${this.authkey}`) { // The live data invalidator will re-request the data if it is not
log.warn('agent', 'Rejecting an unauthorized WebSocket connection'); // available in the cache anyways.
const data = JSON.stringify({ NodeIDs: nodeList });
const info = getConnInfo(c); this.spawnProcess.stdin?.write(`${data}\n`);
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<HostInfo>(data)) {
this.cache.set(node, info);
log.debug('agent', 'Cached HostInfo for %s', node);
}
},
};
} }
} }

View File

@ -3,6 +3,11 @@
// https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L816 // https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L816
export interface HostInfo { 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) */ /** Version of this code (in version.Long format) */
IPNVersion?: string; IPNVersion?: string;

View File

@ -43,8 +43,34 @@ headscale:
config_strict: true config_strict: true
# Integration configurations for Headplane to interact with Headscale # Integration configurations for Headplane to interact with Headscale
# Only one of these should be enabled at a time or you will get errors
integration: 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: "<your-preauth-key>"
# 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: docker:
enabled: false enabled: false
# Preferred method: use container_label to dynamically discover the Headscale container. # Preferred method: use container_label to dynamically discover the Headscale container.

View File

@ -17,7 +17,6 @@
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/inter": "^5.1.1", "@fontsource-variable/inter": "^5.1.1",
"@hono/node-server": "^1.14.0",
"@kubernetes/client-node": "^0.22.3", "@kubernetes/client-node": "^0.22.3",
"@primer/octicons-react": "^19.14.0", "@primer/octicons-react": "^19.14.0",
"@react-aria/toast": "3.0.0-beta.18", "@react-aria/toast": "3.0.0-beta.18",
@ -43,13 +42,12 @@
"react-error-boundary": "^5.0.0", "react-error-boundary": "^5.0.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router": "^7.4.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", "react-stately": "^3.35.0",
"remix-utils": "^8.0.0", "remix-utils": "^8.0.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"undici": "^7.2.0", "undici": "^7.2.0",
"usehooks-ts": "^3.1.0", "usehooks-ts": "^3.1.0",
"ws": "^8.18.1",
"yaml": "^2.7.0", "yaml": "^2.7.0",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
@ -58,7 +56,6 @@
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@react-router/dev": "^7.4.0", "@react-router/dev": "^7.4.0",
"@types/websocket": "^1.0.10", "@types/websocket": "^1.0.10",
"@types/ws": "^8.5.13",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"babel-plugin-react-compiler": "19.0.0-beta-55955c9-20241229", "babel-plugin-react-compiler": "19.0.0-beta-55955c9-20241229",
"lefthook": "^1.10.9", "lefthook": "^1.10.9",

View File

@ -1,8 +1,8 @@
diff --git a/dist/adapters/node.d.ts b/dist/adapters/node.d.ts 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 --- a/dist/adapters/node.d.ts
+++ b/dist/adapters/node.d.ts +++ b/dist/adapters/node.d.ts
@@ -50,6 +50,10 @@ interface HonoNodeServerOptions<E extends Env = BlankEnv> extends HonoServerOpti @@ -51,6 +51,10 @@ interface HonoNodeServerOptions<E extends Env = BlankEnv> extends HonoServerOpti
/** /**
* Callback executed just after `serve` from `@hono/node-server` * 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. * 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 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 --- a/dist/adapters/node.js
+++ b/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); await mergedOptions.beforeAll?.(app);
app.use( app.use(
- `/${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`, - `/${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), cache(60 * 60 * 24 * 365),
// 1 year // 1 year
- serveStatic({ root: clientBuildPath }) - serveStatic({ root: clientBuildPath, ...mergedOptions.serveStaticOptions?.clientAssets })
+ serveStatic({ + serveStatic({
+ root: clientBuildPath, + root: clientBuildPath,
+ rewriteRequestPath: path => path.replace(__PREFIX__, "/") + ...mergedOptions.serveStaticOptions?.clientAssets,
+ }) + rewriteRequestPath: path => path.replace(__PREFIX__, "/")
+ })
); );
app.use( app.use(
- "*", - "*",
+ `${__PREFIX__}/assets/*`, + `${__PREFIX__}/*`,
cache(60 * 60), cache(60 * 60),
// 1 hour // 1 hour
- serveStatic({ root: PRODUCTION ? clientBuildPath : "./public" }) - serveStatic({ root: PRODUCTION ? clientBuildPath : "./public", ...mergedOptions.serveStaticOptions?.publicAssets })
+ serveStatic({ + serveStatic({
+ root: PRODUCTION ? clientBuildPath : "./public", + root: PRODUCTION ? clientBuildPath : "./public",
+ rewriteRequestPath: path => path.replace(__PREFIX__, "/") + ...mergedOptions.serveStaticOptions?.publicAssets,
+ }) + rewriteRequestPath: path => path.replace(__PREFIX__, "/")
+ })
); );
if (mergedOptions.defaultLogger) { if (mergedOptions.defaultLogger) {
app.use("*", logger()); 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

View File

@ -9,7 +9,7 @@ patchedDependencies:
hash: 915164bae9a5d47bb0e7edf0cbbc4c7f0fedb1a2f9a5f6ef5c53d8fef6856211 hash: 915164bae9a5d47bb0e7edf0cbbc4c7f0fedb1a2f9a5f6ef5c53d8fef6856211
path: patches/@shopify__lang-jsonc@1.0.0.patch path: patches/@shopify__lang-jsonc@1.0.0.patch
react-router-hono-server: react-router-hono-server:
hash: c547fd5480e282b40f1c4e668ab066a78abdde8fe04871a65bda306896b0a39e hash: 99b0b196753142b8bd30e3d98d42462fbea5d06cebfd17d820b073e7ec51e6c3
path: patches/react-router-hono-server.patch path: patches/react-router-hono-server.patch
importers: importers:
@ -31,9 +31,6 @@ importers:
'@fontsource-variable/inter': '@fontsource-variable/inter':
specifier: ^5.1.1 specifier: ^5.1.1
version: 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': '@kubernetes/client-node':
specifier: ^0.22.3 specifier: ^0.22.3
version: 0.22.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) version: 0.22.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)
@ -110,8 +107,8 @@ importers:
specifier: ^7.4.0 specifier: ^7.4.0
version: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) version: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react-router-hono-server: react-router-hono-server:
specifier: ^2.11.0 specifier: ^2.13.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)) 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: react-stately:
specifier: ^3.35.0 specifier: ^3.35.0
version: 3.35.0(react@19.0.0) version: 3.35.0(react@19.0.0)
@ -127,9 +124,6 @@ importers:
usehooks-ts: usehooks-ts:
specifier: ^3.1.0 specifier: ^3.1.0
version: 3.1.0(react@19.0.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: yaml:
specifier: ^2.7.0 specifier: ^2.7.0
version: 2.7.0 version: 2.7.0
@ -149,9 +143,6 @@ importers:
'@types/websocket': '@types/websocket':
specifier: ^1.0.10 specifier: ^1.0.10
version: 1.0.10 version: 1.0.10
'@types/ws':
specifier: ^8.5.13
version: 8.5.13
autoprefixer: autoprefixer:
specifier: ^10.4.21 specifier: ^10.4.21
version: 10.4.21(postcss@8.5.3) version: 10.4.21(postcss@8.5.3)
@ -457,8 +448,8 @@ packages:
'@codemirror/commands@6.7.1': '@codemirror/commands@6.7.1':
resolution: {integrity: sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==} resolution: {integrity: sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==}
'@codemirror/commands@6.8.0': '@codemirror/commands@6.8.1':
resolution: {integrity: sha512-q8VPEFaEP4ikSlt6ZxjB3zW72+7osfAYW9i8Zu943uqbKuz6utc1+F170hyLUCUltXORjQXRyYQNfkckzA/bPQ==} resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
'@codemirror/language@6.10.8': '@codemirror/language@6.10.8':
resolution: {integrity: sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw==} resolution: {integrity: sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw==}
@ -980,15 +971,15 @@ packages:
peerDependencies: peerDependencies:
hono: ^4 hono: ^4
'@hono/node-ws@1.1.0': '@hono/node-ws@1.1.1':
resolution: {integrity: sha512-uHaz1EPguJqsUmA+Jmhdi/DTRAMs2Fvcy7qno9E48rlK3WBtyGQw4u4DKlc+o18Nh1DGz2oA1n9hCzEyhVBeLw==} resolution: {integrity: sha512-iFJrAw5GuBTstehBzLY2FyW5rRlXmO3Uwpijpm4Liv75owNP/UjZe3KExsLuEK4w+u+xhvHqOoQUyEKWUvyghw==}
engines: {node: '>=18.14.1'} engines: {node: '>=18.14.1'}
peerDependencies: peerDependencies:
'@hono/node-server': ^1.11.1 '@hono/node-server': ^1.11.1
hono: ^4.6.0 hono: ^4.6.0
'@hono/vite-dev-server@0.17.0': '@hono/vite-dev-server@0.19.0':
resolution: {integrity: sha512-EvGOIj1MoY9uV94onXXz88yWaTxzUK+Mv8LiIEsR/9eSFoVUnHVR0B7l7iNIsxfHYRN7tbPDMWBSnD2RQun3yw==} resolution: {integrity: sha512-myMc4Nm0nFQSPaeE6I/a1ODyDR5KpQ4EHodX4Tu/7qlB31GfUemhUH/WsO91HJjDEpRRpsT4Zbg+PleMlpTljw==}
engines: {node: '>=18.14.1'} engines: {node: '>=18.14.1'}
peerDependencies: peerDependencies:
hono: '*' hono: '*'
@ -1826,9 +1817,6 @@ packages:
'@types/node@20.17.16': '@types/node@20.17.16':
resolution: {integrity: sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==} resolution: {integrity: sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==}
'@types/node@22.10.1':
resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==}
'@types/node@22.10.7': '@types/node@22.10.7':
resolution: {integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==} resolution: {integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==}
@ -1843,9 +1831,6 @@ packages:
'@types/websocket@1.0.10': '@types/websocket@1.0.10':
resolution: {integrity: sha512-svjGZvPB7EzuYS94cI7a+qhwgGU1y89wUgjT6E2wVUfmAGIvRfT7obBvRtnhXCSsoMdlG4gBFGE7MfkIXZLoww==} 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': '@uiw/codemirror-extensions-basic-setup@4.23.7':
resolution: {integrity: sha512-9/2EUa1Lck4kFKkR2BkxlZPpgD/EWuKHnOlysf1yHKZGraaZmZEaUw+utDK4QcuJc8Iz097vsLz4f4th5EU27g==} resolution: {integrity: sha512-9/2EUa1Lck4kFKkR2BkxlZPpgD/EWuKHnOlysf1yHKZGraaZmZEaUw+utDK4QcuJc8Iz097vsLz4f4th5EU27g==}
peerDependencies: peerDependencies:
@ -2270,8 +2255,8 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
hono@4.7.5: hono@4.7.6:
resolution: {integrity: sha512-fDOK5W2C1vZACsgLONigdZTRZxuBqFtcKh7bUQ5cVSbwI2RWjloJDcgFOVzbQrlI6pCmhlTsVYZ7zpLj4m4qMQ==} resolution: {integrity: sha512-564rVzELU+9BRqqx5k8sT2NFwGD3I3Vifdb6P7CmM6FiarOSY+fDC+6B+k9wcCb86ReoayteZP2ki0cRLN1jbw==}
engines: {node: '>=16.9.0'} engines: {node: '>=16.9.0'}
hosted-git-info@6.1.3: hosted-git-info@6.1.3:
@ -2755,18 +2740,18 @@ packages:
react: '>=18' react: '>=18'
react-dom: '>=18' react-dom: '>=18'
react-router-hono-server@2.11.0: react-router-hono-server@2.13.0:
resolution: {integrity: sha512-zn0kJUUamgxYS7mMDLv0kHCJE1UTX0bYNdfJeBLjw0xr/gnre0ttEZ2LTsFM8re1P2iMQ64mftpnSyeXIPijOA==} resolution: {integrity: sha512-YcxmFpphZL9Jc4CnOgsKw35wcurmiOpTg3vBzjOROIUhEo6rKvUHOg7AM2QJi2Fa8BRJK6MvHaN4haedYo1iiA==}
engines: {node: '>=22.12.0'} engines: {node: '>=22.12.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
'@cloudflare/workers-types': ^4.20241112.0 '@cloudflare/workers-types': ^4.20250317.0
'@react-router/dev': ^7.2.0 '@react-router/dev': ^7.2.0
'@types/react': ^18.3.10 || ^19.0.0 '@types/react': ^18.3.10 || ^19.0.0
miniflare: ^3.20241205.0 miniflare: ^3.20241205.0
react-router: ^7.2.0 react-router: ^7.2.0
vite: ^5.1.0 || ^6.0.0 vite: ^6.0.0
wrangler: ^3.91.0 wrangler: ^4.2.0
peerDependenciesMeta: peerDependenciesMeta:
'@cloudflare/workers-types': '@cloudflare/workers-types':
optional: true optional: true
@ -3679,7 +3664,7 @@ snapshots:
'@codemirror/view': 6.36.1 '@codemirror/view': 6.36.1
'@lezer/common': 1.2.3 '@lezer/common': 1.2.3
'@codemirror/commands@6.8.0': '@codemirror/commands@6.8.1':
dependencies: dependencies:
'@codemirror/language': 6.10.8 '@codemirror/language': 6.10.8
'@codemirror/state': 6.5.0 '@codemirror/state': 6.5.0
@ -4015,23 +4000,23 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 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: 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: dependencies:
'@hono/node-server': 1.14.0(hono@4.7.5) '@hono/node-server': 1.14.0(hono@4.7.6)
hono: 4.7.5 hono: 4.7.6
ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
- utf-8-validate - 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: dependencies:
'@hono/node-server': 1.14.0(hono@4.7.5) '@hono/node-server': 1.14.0(hono@4.7.6)
hono: 4.7.5 hono: 4.7.6
minimatch: 9.0.5 minimatch: 9.0.5
'@internationalized/date@3.6.0': '@internationalized/date@3.6.0':
@ -5325,10 +5310,6 @@ snapshots:
dependencies: dependencies:
undici-types: 6.19.8 undici-types: 6.19.8
'@types/node@22.10.1':
dependencies:
undici-types: 6.20.0
'@types/node@22.10.7': '@types/node@22.10.7':
dependencies: dependencies:
undici-types: 6.20.0 undici-types: 6.20.0
@ -5345,10 +5326,6 @@ snapshots:
dependencies: dependencies:
'@types/node': 22.10.7 '@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)': '@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: 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/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): codemirror@6.0.1(@lezer/common@1.2.3):
dependencies: 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/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/language': 6.10.8
'@codemirror/lint': 6.8.2 '@codemirror/lint': 6.8.2
'@codemirror/search': 6.5.7 '@codemirror/search': 6.5.7
@ -5821,7 +5798,7 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
hono@4.7.5: {} hono@4.7.6: {}
hosted-git-info@6.1.3: hosted-git-info@6.1.3:
dependencies: dependencies:
@ -6255,15 +6232,15 @@ snapshots:
react-dom: 19.0.0(react@19.0.0) 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: 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: dependencies:
'@drizzle-team/brocli': 0.11.0 '@drizzle-team/brocli': 0.11.0
'@hono/node-server': 1.14.0(hono@4.7.5) '@hono/node-server': 1.14.0(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)
'@hono/vite-dev-server': 0.17.0(hono@4.7.5) '@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) '@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 '@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) 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) 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: transitivePeerDependencies: