package service import ( "encoding/json" "fmt" "sort" "strings" "time" "browser.local/platform/domain" "browser.local/platform/repo" "browser.local/platform/validator" ) const defaultGameClientBridgeCommandRetention = 30 * 24 * time.Hour func (svc *CoreService) QueueGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) { request.Payload = domain.CopyGameClientBridgePayload(request.Payload) if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil { return domain.GameClientBridgeCommand{}, err } user, err := svc.GetCurrentUser(sessionID) if err != nil { return domain.GameClientBridgeCommand{}, err } instance, err := svc.store.ServerInstances().Get(request.ServerInstanceID) if err != nil { return domain.GameClientBridgeCommand{}, err } if !canAccessServer(user, instance) { return domain.GameClientBridgeCommand{}, ErrForbidden } if instance.PluginID != request.PluginID { return domain.GameClientBridgeCommand{}, validationError("bridge command plugin must match server instance") } return svc.queueGameClientBridgeCommand(user.ID, request) } func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverInstanceID string) (domain.GameClientBridgeStatus, error) { if err := svc.authorizeServerLifecycle(sessionID, serverInstanceID); err != nil { return domain.GameClientBridgeStatus{}, err } instance, err := svc.store.ServerInstances().Get(serverInstanceID) if err != nil { return domain.GameClientBridgeStatus{}, err } plugin, err := svc.store.GamePlugins().Get(instance.PluginID) if err != nil { return domain.GameClientBridgeStatus{}, err } status := domain.GameClientBridgeStatus{ServerInstanceID: instance.ID, PluginID: plugin.ID, Reason: "plugin does not declare a game client bridge", Profiles: []domain.GameClientBridgeProfileDeclaration{}, Features: []domain.GameClientBridgeFeatureAvailability{}} if !gameClientBridgeDeclared(plugin.GameClientBridge) { return domain.CopyGameClientBridgeStatus(status), nil } commandTypes := gameClientBridgeCommandTypes(plugin.GameClientBridge.Commands) snapshotTypes := gameClientBridgeSnapshotTypes(plugin.GameClientBridge.Snapshots) declaration := domain.GameClientBridgeProfileDeclaration{PluginID: plugin.ID, ProfileKey: "plugin-owned", Available: true, CommandTypes: commandTypes, SnapshotTypes: snapshotTypes, QueryTemplateKeys: gameClientBridgeQueryTemplateKeys(plugin.GameClientBridge.QueryTemplates), HandlerTypes: commandTypes, EventProducerTypes: snapshotTypes} if ready, reason := svc.gameClientBridgeRuntimeReadiness(instance, plugin); !ready { declaration.Available = false declaration.Reason = reason status.Reason = reason } status.Profiles = append(status.Profiles, declaration) status.Available = declaration.Available if status.Available { status.Reason = "" } status.Features = gameClientBridgeFeatureAvailability(plugin.GameClientBridge.Features, status.Profiles) return domain.CopyGameClientBridgeStatus(status), nil } func gameClientBridgeDeclared(bridge domain.GameClientBridgeManifest) bool { return len(bridge.Commands) > 0 || len(bridge.Snapshots) > 0 || len(bridge.QueryTemplates) > 0 || len(bridge.LifecycleProjections) > 0 || len(bridge.DataPacks) > 0 || len(bridge.Pages) > 0 || len(bridge.Features) > 0 } func (svc *CoreService) gameClientBridgeRuntimeReadiness(instance domain.ServerInstance, plugin domain.GamePlugin) (bool, string) { if len(plugin.GameClientBridge.QueryTemplates) == 0 { return true, "" } endpoint, err := svc.GetRunEndpoint(instance.RunEndpointID) if err != nil { return false, "Run endpoint is not registered" } if endpoint.Status == domain.RunEndpointStatusOffline || endpoint.Status == domain.RunEndpointStatusDisabled { return false, "Run heartbeat has not been observed" } transports := map[string]domain.RuntimeTransportProfile{} for _, transport := range plugin.RuntimeProfiles.TransportProfiles { transports[transport.Key] = transport } for _, template := range plugin.GameClientBridge.QueryTemplates { transport, ok := transports[template.TransportKey] if !ok { return false, "bridge query transport is not declared" } for _, capability := range transport.Capabilities { if !svc.endpointSupports(endpoint, capability) { return false, "Run endpoint is missing bridge transport capability" } } } return true, "" } func gameClientBridgeFeatureAvailability(features []domain.GameClientBridgeFeatureDeclaration, profiles []domain.GameClientBridgeProfileDeclaration) []domain.GameClientBridgeFeatureAvailability { result := make([]domain.GameClientBridgeFeatureAvailability, 0, len(features)) for _, feature := range features { handlers, producers := map[string]bool{}, map[string]bool{} for _, profile := range profiles { if !profile.Available { continue } for _, handler := range profile.HandlerTypes { handlers[handler] = true } for _, producer := range profile.EventProducerTypes { producers[producer] = true } } missing := make([]string, 0) for _, handler := range feature.RequiredHandlers { if !handlers[handler] { missing = append(missing, "handler."+handler) } } for _, producer := range feature.RequiredEventProducers { if !producers[producer] { missing = append(missing, "event-producer."+producer) } } availability := domain.GameClientBridgeFeatureAvailability{Key: feature.Key, Available: len(missing) == 0} if len(missing) > 0 { availability.Reason = "compatible Companion is missing " + strings.Join(missing, ", ") } result = append(result, availability) } return result } func (svc *CoreService) ListGameClientBridgeCommandsForSession(sessionID string, filter domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error) { if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { return nil, err } instance, err := svc.store.ServerInstances().Get(filter.ServerInstanceID) if err != nil { return nil, err } filter.PluginID = instance.PluginID commands, err := svc.store.GameClientBridgeCommands().List(filter) if err != nil { return nil, err } result := make([]domain.GameClientBridgeCommand, len(commands)) for index, command := range commands { result[index] = domain.CopyGameClientBridgeCommand(command) } return result, nil } func (svc *CoreService) GetGameClientBridgeCommandForSession(sessionID, commandID string) (domain.GameClientBridgeCommand, error) { command, err := svc.store.GameClientBridgeCommands().Get(commandID) if err != nil { return domain.GameClientBridgeCommand{}, err } if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil { return domain.GameClientBridgeCommand{}, err } return domain.CopyGameClientBridgeCommand(command), nil } func (svc *CoreService) QueryGameClientBridgeSnapshotsForSession(sessionID string, query domain.GameClientBridgeSnapshotQuery) ([]domain.GameClientBridgeSnapshot, error) { if err := validator.ValidateGameClientBridgeSnapshotQuery(query); err != nil { return nil, err } if err := svc.authorizeServerLifecycle(sessionID, query.ServerInstanceID); err != nil { return nil, err } instance, err := svc.store.ServerInstances().Get(query.ServerInstanceID) if err != nil { return nil, err } if query.PluginID != instance.PluginID { return nil, ErrForbidden } limit := query.Limit if limit == 0 { limit = 50 } snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{ServerInstanceID: query.ServerInstanceID, PluginID: query.PluginID, ProfileKey: query.ProfileKey, Type: query.Type, StreamKey: query.StreamKey, ObservedAfter: query.ObservedAfter, Limit: limit}) if err != nil { return nil, err } result := make([]domain.GameClientBridgeSnapshot, len(snapshots)) for index, snapshot := range snapshots { result[index] = domain.CopyGameClientBridgeSnapshot(snapshot) } return result, nil } func gameClientBridgeCommandDeclaration(plugin domain.GamePlugin, profileKey, commandType string) (domain.GameClientBridgeCommandDeclaration, bool) { if strings.TrimSpace(profileKey) == "" { return domain.GameClientBridgeCommandDeclaration{}, false } for _, declaration := range plugin.GameClientBridge.Commands { if declaration.Type == commandType { return declaration, true } } return domain.GameClientBridgeCommandDeclaration{}, false } func gameClientBridgeCommandTypes(declarations []domain.GameClientBridgeCommandDeclaration) []string { values := make([]string, len(declarations)) for index, declaration := range declarations { values[index] = declaration.Type } return values } func gameClientBridgeSnapshotTypes(declarations []domain.GameClientBridgeSnapshotDeclaration) []string { seen := map[string]struct{}{} values := make([]string, 0, len(declarations)) for _, declaration := range declarations { if _, exists := seen[declaration.Type]; exists { continue } seen[declaration.Type] = struct{}{} values = append(values, declaration.Type) } return values } func gameClientBridgeSnapshotDeclaration(plugin domain.GamePlugin, snapshotType, schemaVersion string) (domain.GameClientBridgeSnapshotDeclaration, bool) { for _, declaration := range plugin.GameClientBridge.Snapshots { if declaration.Type == snapshotType && declaration.SchemaVersion == schemaVersion { return declaration, true } } return domain.GameClientBridgeSnapshotDeclaration{}, false } func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQueryTemplateDeclaration) []string { values := make([]string, len(declarations)) for index, declaration := range declarations { values[index] = declaration.Key } return values } func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) { request.Payload = domain.CopyGameClientBridgePayload(request.Payload) if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil { return domain.GameClientBridgeCommand{}, err } stamp := svc.now() svc.bridgeMu.Lock() defer svc.bridgeMu.Unlock() plugin, err := svc.store.GamePlugins().Get(request.PluginID) if err != nil { return domain.GameClientBridgeCommand{}, err } declaration, declared := gameClientBridgeCommandDeclaration(plugin, request.ProfileKey, request.CommandType) if !declared { return domain.GameClientBridgeCommand{}, validationError("bridge command type is not declared for the profile") } payload, err := json.Marshal(request.Payload) if err != nil || len(payload) > declaration.MaxPayloadBytes { return domain.GameClientBridgeCommand{}, validationError("bridge command payload exceeds declaration") } if request.ExpiresAt.After(stamp.Add(time.Duration(declaration.TimeoutSeconds) * time.Second)) { return domain.GameClientBridgeCommand{}, validationError("bridge command expiry exceeds declared timeout") } existing, err := svc.store.GameClientBridgeCommands().GetByIdempotency(request.ServerInstanceID, requesterID, request.CommandType, request.IdempotencyKey) if err == nil { return domain.CopyGameClientBridgeCommand(existing), nil } if err != nil && err != repo.ErrNotFound { return domain.GameClientBridgeCommand{}, err } if !request.ExpiresAt.After(stamp) { return domain.GameClientBridgeCommand{}, validationError("bridge command expiresAt must be in the future") } svc.bridgeSeq++ command := domain.GameClientBridgeCommand{ ID: fmt.Sprintf("bridge-command-%d-%d", stamp.UnixNano(), svc.bridgeSeq), ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, ProfileKey: request.ProfileKey, CommandType: request.CommandType, Payload: domain.CopyGameClientBridgePayload(request.Payload), IdempotencyKey: request.IdempotencyKey, Priority: request.Priority, State: domain.GameClientBridgeCommandPending, RequesterID: requesterID, ExpiresAt: request.ExpiresAt, CreatedAt: stamp, UpdatedAt: stamp, } if err := svc.store.GameClientBridgeCommands().Create(command); err != nil { return domain.GameClientBridgeCommand{}, err } return domain.CopyGameClientBridgeCommand(command), nil } func (svc *CoreService) CancelGameClientBridgeCommandForSession(sessionID string, request domain.GameClientBridgeCancelRequest) (domain.GameClientBridgeCommand, error) { if err := validator.ValidateGameClientBridgeCancelRequest(request); err != nil { return domain.GameClientBridgeCommand{}, err } user, err := svc.GetCurrentUser(sessionID) if err != nil { return domain.GameClientBridgeCommand{}, err } stamp := svc.now() svc.bridgeMu.Lock() defer svc.bridgeMu.Unlock() command, err := svc.store.GameClientBridgeCommands().Get(request.CommandID) if err != nil { return domain.GameClientBridgeCommand{}, err } instance, err := svc.store.ServerInstances().Get(command.ServerInstanceID) if err != nil { return domain.GameClientBridgeCommand{}, err } if !canAccessServer(user, instance) { return domain.GameClientBridgeCommand{}, ErrForbidden } if !isTerminalGameClientBridgeCommandState(command.State) && !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) { if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil { return domain.GameClientBridgeCommand{}, err } return domain.GameClientBridgeCommand{}, validationError("bridge command expired") } if isTerminalGameClientBridgeCommandState(command.State) { if command.State == domain.GameClientBridgeCommandCancelled { return domain.CopyGameClientBridgeCommand(command), nil } return domain.GameClientBridgeCommand{}, validationError("bridge command is already terminal") } command.State = domain.GameClientBridgeCommandCancelled command.Cancellation = domain.GameClientBridgeCancellation{RequestedBy: user.ID, Reason: request.Reason, CancelledAt: stamp} command.Result = domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultCancelled, Summary: "cancelled by operator", CompletedAt: stamp} command.CompletedAt = stamp command.UpdatedAt = stamp if err := svc.store.GameClientBridgeCommands().Update(command); err != nil { return domain.GameClientBridgeCommand{}, err } return domain.CopyGameClientBridgeCommand(command), nil } func (svc *CoreService) ReconcileGameClientBridgeCommands() error { stamp := svc.now() svc.bridgeMu.Lock() defer svc.bridgeMu.Unlock() if err := svc.sweepGameClientBridgeCommandsLocked(stamp); err != nil { return err } if err := svc.pruneGameClientBridgeCommandsLocked(stamp); err != nil { return err } return svc.pruneGameClientBridgeSnapshotsLocked(stamp) } func (svc *CoreService) pruneGameClientBridgeCommandsLocked(stamp time.Time) error { commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{}) if err != nil { return err } groups := map[string][]domain.GameClientBridgeCommand{} for _, command := range commands { if !isTerminalGameClientBridgeCommandState(command.State) || command.CompletedAt.IsZero() { continue } retention := defaultGameClientBridgeCommandRetention maxRecords := 0 if plugin, pluginErr := svc.store.GamePlugins().Get(command.PluginID); pluginErr == nil { if plugin.GameClientBridge.Retention.KeepForSeconds > 0 { retention = time.Duration(plugin.GameClientBridge.Retention.KeepForSeconds) * time.Second } maxRecords = plugin.GameClientBridge.Retention.MaxRecords } if !command.CompletedAt.After(stamp.Add(-retention)) { if err := svc.store.GameClientBridgeCommands().Delete(command.ID); err != nil { return err } continue } if maxRecords > 0 { key := command.ServerInstanceID + "\x00" + command.PluginID groups[key] = append(groups[key], command) } } for _, group := range groups { if len(group) == 0 { continue } plugin, err := svc.store.GamePlugins().Get(group[0].PluginID) if err != nil || plugin.GameClientBridge.Retention.MaxRecords <= 0 || len(group) <= plugin.GameClientBridge.Retention.MaxRecords { continue } sort.SliceStable(group, func(left, right int) bool { if !group[left].CompletedAt.Equal(group[right].CompletedAt) { return group[left].CompletedAt.After(group[right].CompletedAt) } return group[left].ID < group[right].ID }) for _, command := range group[plugin.GameClientBridge.Retention.MaxRecords:] { if err := svc.store.GameClientBridgeCommands().Delete(command.ID); err != nil { return err } } } return nil } func (svc *CoreService) pruneGameClientBridgeSnapshotsLocked(stamp time.Time) error { snapshots, err := svc.store.GameClientBridgeSnapshots().List(domain.GameClientBridgeSnapshotFilter{}) if err != nil { return err } groups := map[string][]domain.GameClientBridgeSnapshot{} for _, snapshot := range snapshots { if !snapshot.ExpiresAt.IsZero() && !snapshot.ExpiresAt.After(stamp) { if err := svc.store.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil { return err } continue } key := snapshot.ServerInstanceID + "\x00" + snapshot.PluginID + "\x00" + snapshot.ProfileKey + "\x00" + snapshot.Type + "\x00" + snapshot.StreamKey groups[key] = append(groups[key], snapshot) } for _, group := range groups { if len(group) == 0 { continue } maxRecords := group[0].Retention.MaxRecords if plugin, pluginErr := svc.store.GamePlugins().Get(group[0].PluginID); pluginErr == nil { if declaration, ok := gameClientBridgeSnapshotDeclaration(plugin, group[0].Type, group[0].SchemaVersion); ok { maxRecords = declaration.Retention.MaxRecords } } if maxRecords <= 0 || len(group) <= maxRecords { continue } sort.SliceStable(group, func(left, right int) bool { if group[left].Sequence != group[right].Sequence { return group[left].Sequence > group[right].Sequence } return group[left].ID < group[right].ID }) for _, snapshot := range group[maxRecords:] { if err := svc.store.GameClientBridgeSnapshots().Delete(snapshot.ID); err != nil { return err } } } return nil } func (svc *CoreService) sweepGameClientBridgeCommandsLocked(stamp time.Time) error { commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{}) if err != nil { return err } for _, command := range commands { if isTerminalGameClientBridgeCommandState(command.State) { continue } if !command.ExpiresAt.IsZero() && !command.ExpiresAt.After(stamp) { if err := svc.expireGameClientBridgeCommandLocked(command, stamp); err != nil { return err } continue } if command.State == domain.GameClientBridgeCommandClaimed && !command.Claim.LeaseExpiresAt.IsZero() && !command.Claim.LeaseExpiresAt.After(stamp) { fencingToken := command.Claim.FencingToken command.State = domain.GameClientBridgeCommandPending command.Claim = domain.GameClientBridgeClaim{FencingToken: fencingToken} command.UpdatedAt = stamp if err := svc.store.GameClientBridgeCommands().Update(command); err != nil { return err } } } return nil } func (svc *CoreService) expireGameClientBridgeCommandLocked(command domain.GameClientBridgeCommand, stamp time.Time) error { if isTerminalGameClientBridgeCommandState(command.State) { return nil } command.State = domain.GameClientBridgeCommandExpired command.CompletedAt = stamp command.UpdatedAt = stamp return svc.store.GameClientBridgeCommands().Update(command) } func isTerminalGameClientBridgeCommandState(state domain.GameClientBridgeCommandState) bool { switch state { case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandUnknown, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired: return true default: return false } }