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
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
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" ]

View File

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

View File

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

View File

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

View File

@ -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()
for {
_, message, err := s.ReadMessage()
if err != nil {
log.Error("Error reading message: %s", err)
return
type SendMessage struct {
Type string
Data any
}
// 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,
})
}
// Stops listening for messages from the Headplane master
func (s *Socket) StopListening() {
s.Close()
if err := scanner.Err(); err != nil {
log.Fatal("Error reading from stdin: %s", err)
}
}

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 (
"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.

View File

@ -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)
// 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)
}
func (logger *Logger) Debug(fmt string, v ...any) {
if logger.debug != nil {
logger.debug.Printf(fmt, v...)
// 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)
}

View File

@ -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() {
</div>
</th>
{/* 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>
) : undefined}
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
@ -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

View File

@ -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({

View File

@ -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<WebSocket>)((c) =>
agentManager.configureSocket(c),
),
);
}
},
listeningListener(info) {
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 { 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<string, HostInfo>;
}
interface MessageResponse {
Level: 'msg';
Message: RegisterMessage | StatusMessage;
}
type AgentResponse = LogResponse | MessageResponse;
export async function loadAgentSocket(
authkey: string,
path: string,
ttl: number,
config: NonNullable<HeadplaneConfig['integration']>['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<HostInfo>(ttl, path);
return new AgentManager(cache, authkey);
const cache = new TimedCache<HostInfo>(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<HostInfo>;
private agents: Map<string, WSContext>;
private timers: Map<string, NodeJS.Timeout>;
private authkey: string;
private headscaleUrl: string;
private config: NonNullable<
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.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 }));
}
}
// Since we are using Node, Hono is built on 'ws' WebSocket types.
configureSocket(c: Context): WSEvents<WebSocket> {
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');
// 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;
}
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);
// Wait for the process to be spawned, busy waiting is gross
while (this.spawnProcess === null) {
await setTimeout(100);
}
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);
}
},
};
// 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`);
}
}

View File

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

View File

@ -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: "<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:
enabled: false
# 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/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",

View File

@ -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<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`
*/
@ -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,
+ ...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",
+ ...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

View File

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