feat: move session sharing and collab rooms into a plugin (B12)

This commit is contained in:
LukeGus committed 2026-09-23 23:42:46 -05:00
1 parent 791d8656a1
commit a276f6898c
207 files changed
+35771 -11072

No files matched your search

+23 -9
View File
@@ -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: {
+2 -2
View File
@@ -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
+69 -24
View File
@@ -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.