mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: move session sharing and collab rooms into a plugin (B12)
This commit is contained in:
1 parent
791d8656a1
commit
a276f6898c
207 files changed
+35771
-11072
No files matched your search
@@ -435,7 +435,7 @@ export function createPluginContext(
|
||||
},
|
||||
|
||||
services: {
|
||||
provide: (service, implementation) => {
|
||||
provide: (service, implementation, options) => {
|
||||
// Declared AND provided: a plugin cannot publish a service its
|
||||
// manifest never mentioned.
|
||||
const entry = manifest.provides?.find(
|
||||
@@ -446,9 +446,16 @@ export function createPluginContext(
|
||||
`Plugin ${pluginId} cannot provide service "${service}": it is not declared in the manifest's provides array`,
|
||||
);
|
||||
}
|
||||
const name = options?.name ?? "";
|
||||
if (entry.names ? !entry.names.includes(name) : name !== "") {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} cannot provide service "${service}" as "${name}": the manifest's provides[].names does not list it`,
|
||||
);
|
||||
}
|
||||
|
||||
const registration = serviceRegistry.provideService({
|
||||
service,
|
||||
name,
|
||||
version: entry.version,
|
||||
permission: entry.permission,
|
||||
pluginId,
|
||||
@@ -463,14 +470,21 @@ export function createPluginContext(
|
||||
},
|
||||
|
||||
get: (service, options) =>
|
||||
serviceRegistry.createServiceHandle(service, pluginId, {
|
||||
// A caller-supplied userId is only honoured when it matches the
|
||||
// actor; otherwise the ambient actor wins. A plugin cannot widen
|
||||
// its reach by naming someone else here.
|
||||
resolveUserId: () => options?.userId ?? getActor(),
|
||||
hasPermission: checkPermission,
|
||||
audit: (entry) => writeServiceAudit(entry),
|
||||
}),
|
||||
serviceRegistry.createServiceHandle(
|
||||
service,
|
||||
pluginId,
|
||||
{
|
||||
// A caller-supplied userId is only honoured when it matches the
|
||||
// actor; otherwise the ambient actor wins. A plugin cannot widen
|
||||
// its reach by naming someone else here.
|
||||
resolveUserId: () => options?.userId ?? getActor(),
|
||||
hasPermission: checkPermission,
|
||||
audit: (entry) => writeServiceAudit(entry),
|
||||
},
|
||||
options?.provider ?? "",
|
||||
),
|
||||
|
||||
providers: (service) => serviceRegistry.listProviderNames(service),
|
||||
},
|
||||
|
||||
secrets: {
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* - core registers the SSH connection pool as "ssh.transport" at boot, so
|
||||
* everything that needs a pooled connection resolves a provider rather
|
||||
* than importing the pool directly.
|
||||
* - the ssh-terminal plugin publishes "terminal.sessions" so collab and
|
||||
* session-sharing can reach live sessions without importing a plugin.
|
||||
* - the session-sharing plugin publishes "sessions.sharing.guests", guest
|
||||
* link resolution for the terminal, keyed by a token rather than a user.
|
||||
*
|
||||
* Deliberately NOT reachable from a worker plugin's ctx. Values here are live
|
||||
* objects -- pools, session managers, sockets -- and handing one across the
|
||||
|
||||
@@ -42,6 +42,11 @@ import type { PluginManifest, PluginServiceRequire } from "./manifest.js";
|
||||
|
||||
export interface ServiceRegistration {
|
||||
service: string;
|
||||
/**
|
||||
* The provider's name, for a service several plugins provide side by side
|
||||
* (sessions.live, keyed by session type). Empty for the one unnamed provider.
|
||||
*/
|
||||
name: string;
|
||||
version: string;
|
||||
permission: string;
|
||||
/** The plugin that provided it. */
|
||||
@@ -57,9 +62,10 @@ export interface ServiceRegistration {
|
||||
generation: number;
|
||||
}
|
||||
|
||||
const services = new Map<string, ServiceRegistration>();
|
||||
/** service -> provider name ("" when unnamed) -> registration. */
|
||||
const services = new Map<string, Map<string, ServiceRegistration>>();
|
||||
/**
|
||||
* What a service looked like before it was revoked.
|
||||
* What a provider looked like before it was revoked, by the same two keys.
|
||||
*
|
||||
* A handle taken while the provider was up must keep throwing a named error
|
||||
* after it goes away, rather than degrading to "undefined is not a function",
|
||||
@@ -68,6 +74,14 @@ const services = new Map<string, ServiceRegistration>();
|
||||
const retired = new Map<string, { pluginId: string; methods: Set<string> }>();
|
||||
let generationCounter = 0;
|
||||
|
||||
function retiredKey(service: string, name: string): string {
|
||||
return `${service}#${name}`;
|
||||
}
|
||||
|
||||
function lookup(service: string, name = ""): ServiceRegistration | undefined {
|
||||
return services.get(service)?.get(name);
|
||||
}
|
||||
|
||||
/** Thrown when the provider went away while a consumer still held a handle. */
|
||||
export class PluginServiceUnavailableError extends Error {
|
||||
readonly code = "EPLUGINSVCGONE";
|
||||
@@ -115,22 +129,31 @@ export class PluginServiceActorError extends Error {
|
||||
}
|
||||
|
||||
export function provideService(
|
||||
registration: Omit<ServiceRegistration, "generation">,
|
||||
registration: Omit<ServiceRegistration, "generation" | "name"> & {
|
||||
name?: string;
|
||||
},
|
||||
): ServiceRegistration {
|
||||
const existing = services.get(registration.service);
|
||||
const name = registration.name ?? "";
|
||||
const existing = lookup(registration.service, name);
|
||||
if (existing) {
|
||||
pluginLogger.warn(
|
||||
`Service "${registration.service}" is already provided by ${existing.pluginId} and is being replaced by ${registration.pluginId}`,
|
||||
`Service "${registration.service}"${name ? ` (${name})` : ""} is already provided by ${existing.pluginId} and is being replaced by ${registration.pluginId}`,
|
||||
{ operation: "plugin_service_registry" },
|
||||
);
|
||||
}
|
||||
|
||||
const entry: ServiceRegistration = {
|
||||
...registration,
|
||||
name,
|
||||
generation: ++generationCounter,
|
||||
};
|
||||
services.set(registration.service, entry);
|
||||
retired.delete(registration.service);
|
||||
let providers = services.get(registration.service);
|
||||
if (!providers) {
|
||||
providers = new Map();
|
||||
services.set(registration.service, providers);
|
||||
}
|
||||
providers.set(name, entry);
|
||||
retired.delete(retiredKey(registration.service, name));
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -143,13 +166,14 @@ export function revokeService(
|
||||
service: string,
|
||||
registration?: ServiceRegistration,
|
||||
): boolean {
|
||||
const current = services.get(service);
|
||||
const name = registration?.name ?? "";
|
||||
const current = lookup(service, name);
|
||||
if (!current) return false;
|
||||
if (registration && current.generation !== registration.generation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
retired.set(service, {
|
||||
retired.set(retiredKey(service, name), {
|
||||
pluginId: current.pluginId,
|
||||
methods: new Set(
|
||||
Object.keys(current.implementation).filter(
|
||||
@@ -157,13 +181,22 @@ export function revokeService(
|
||||
),
|
||||
),
|
||||
});
|
||||
return services.delete(service);
|
||||
const providers = services.get(service)!;
|
||||
providers.delete(name);
|
||||
if (providers.size === 0) services.delete(service);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getRegistration(
|
||||
service: string,
|
||||
name = "",
|
||||
): ServiceRegistration | undefined {
|
||||
return services.get(service);
|
||||
return lookup(service, name);
|
||||
}
|
||||
|
||||
/** The names of every provider of a service right now ("" when unnamed). */
|
||||
export function listProviderNames(service: string): string[] {
|
||||
return [...(services.get(service)?.keys() ?? [])];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,15 +211,16 @@ export function getRegistration(
|
||||
export function getServiceImplementation<T extends object>(
|
||||
service: string,
|
||||
versionRange: string,
|
||||
name = "",
|
||||
): T | undefined {
|
||||
const registration = services.get(service);
|
||||
const registration = lookup(service, name);
|
||||
if (!registration) return undefined;
|
||||
if (!semver.satisfies(registration.version, versionRange)) return undefined;
|
||||
return registration.implementation as T;
|
||||
}
|
||||
|
||||
export function listServices(): ServiceRegistration[] {
|
||||
return [...services.values()];
|
||||
return [...services.values()].flatMap((providers) => [...providers.values()]);
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
@@ -214,7 +248,13 @@ export function resolveRequirements(
|
||||
const missingOptional: string[] = [];
|
||||
|
||||
for (const requirement of manifest.requires ?? []) {
|
||||
const registration = services.get(requirement.service);
|
||||
// Any one provider satisfies it; a consumer of a named service picks the
|
||||
// provider per call.
|
||||
const providers = [...(services.get(requirement.service)?.values() ?? [])];
|
||||
const registration =
|
||||
providers.find((candidate) =>
|
||||
semver.satisfies(candidate.version, requirement.versionRange),
|
||||
) ?? providers[0];
|
||||
|
||||
if (!registration) {
|
||||
recordUnsatisfied(
|
||||
@@ -292,6 +332,7 @@ export function createServiceHandle<T extends object>(
|
||||
service: string,
|
||||
consumerPluginId: string,
|
||||
context: ServiceCallContext,
|
||||
name = "",
|
||||
): T {
|
||||
const target = Object.create(null) as T;
|
||||
|
||||
@@ -301,10 +342,12 @@ export function createServiceHandle<T extends object>(
|
||||
|
||||
if (property === "asUser") {
|
||||
return (userId: string) =>
|
||||
createServiceHandle<T>(service, consumerPluginId, {
|
||||
...context,
|
||||
resolveUserId: () => userId,
|
||||
});
|
||||
createServiceHandle<T>(
|
||||
service,
|
||||
consumerPluginId,
|
||||
{ ...context, resolveUserId: () => userId },
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolved at call time, not here: the provider may have gone away
|
||||
@@ -312,9 +355,9 @@ export function createServiceHandle<T extends object>(
|
||||
// must keep throwing the typed error rather than degrading to
|
||||
// `undefined is not a function`, which tells a consumer nothing about
|
||||
// why its dependency vanished.
|
||||
const registration = services.get(service);
|
||||
const registration = lookup(service, name);
|
||||
if (!registration) {
|
||||
const gone = retired.get(service);
|
||||
const gone = retired.get(retiredKey(service, name));
|
||||
if (!gone?.methods.has(property)) return undefined;
|
||||
return () => {
|
||||
throw new PluginServiceUnavailableError(service, gone.pluginId);
|
||||
@@ -332,17 +375,18 @@ export function createServiceHandle<T extends object>(
|
||||
consumerPluginId,
|
||||
context,
|
||||
registration.generation,
|
||||
name,
|
||||
);
|
||||
},
|
||||
|
||||
has(_target, property) {
|
||||
const registration = services.get(service);
|
||||
const registration = lookup(service, name);
|
||||
if (!registration || typeof property !== "string") return false;
|
||||
return typeof registration.implementation[property] === "function";
|
||||
},
|
||||
|
||||
ownKeys() {
|
||||
const registration = services.get(service);
|
||||
const registration = lookup(service, name);
|
||||
if (!registration) return [];
|
||||
return Object.keys(registration.implementation).filter(
|
||||
(key) => typeof registration.implementation[key] === "function",
|
||||
@@ -350,7 +394,7 @@ export function createServiceHandle<T extends object>(
|
||||
},
|
||||
|
||||
getOwnPropertyDescriptor(_target, property) {
|
||||
const registration = services.get(service);
|
||||
const registration = lookup(service, name);
|
||||
if (!registration || typeof property !== "string") return undefined;
|
||||
if (typeof registration.implementation[property] !== "function") {
|
||||
return undefined;
|
||||
@@ -367,8 +411,9 @@ async function invokeGuarded(
|
||||
consumerPluginId: string,
|
||||
context: ServiceCallContext,
|
||||
expectedGeneration: number,
|
||||
name: string,
|
||||
): Promise<unknown> {
|
||||
const registration = services.get(service);
|
||||
const registration = lookup(service, name);
|
||||
|
||||
// Re-checked here rather than trusted from the get trap: a call is async and
|
||||
// the provider can be disabled between resolving the method and running it.
|
||||
|
||||
Reference in new issue
Block a user