Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions SquidStd.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<Project Path="src/SquidStd.Persistence/SquidStd.Persistence.csproj"/>
<Project Path="src/SquidStd.Persistence.MessagePack/SquidStd.Persistence.MessagePack.csproj"/>
<Project Path="src/SquidStd.Plugin.Abstractions/SquidStd.Plugin.Abstractions.csproj"/>
<Project Path="src/SquidStd.Plugin/SquidStd.Plugin.csproj"/>
<Project Path="src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj"/>
<Project Path="src/SquidStd.Services.Core/SquidStd.Services.Core.csproj"/>
<Project Path="src/SquidStd.Storage.Abstractions/SquidStd.Storage.Abstractions.csproj"/>
Expand Down
1 change: 1 addition & 0 deletions docs/articles/plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[!include[](../../src/SquidStd.Plugin/README.md)]
2 changes: 2 additions & 0 deletions docs/articles/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
href: network.md
- name: SquidStd.Plugin.Abstractions
href: plugin-abstractions.md
- name: SquidStd.Plugin
href: plugin.md
- name: SquidStd.Database.Abstractions
href: database-abstractions.md
- name: SquidStd.Database
Expand Down
1 change: 1 addition & 0 deletions docs/tutorials/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,4 @@ one plugin by hand so the contract is fully runnable without external assemblies
## See also

- [SquidStd.Plugin.Abstractions reference](../articles/plugin-abstractions.md)
- [SquidStd.Plugin reference](../articles/plugin.md) - the loader that discovers and configures plugins in dependency order
17 changes: 10 additions & 7 deletions samples/SquidStd.Samples.Plugins/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using DryIoc;
using SquidStd.Core.Data.Bootstrap;
using SquidStd.Plugin.Abstractions.Data;
using SquidStd.Plugin.Abstractions.Interfaces.Plugins;
using SquidStd.Plugin.Extensions;
using SquidStd.Services.Core.Services.Bootstrap;

namespace SquidStd.Samples.Plugins;

Expand Down Expand Up @@ -41,16 +44,16 @@ private static void Main()
{
#region step-2

var container = new Container();
var context = new PluginContext();
context.Data["startedAt"] = DateTimeOffset.UtcNow;

ISquidStdPlugin plugin = new WeatherPlugin();
var bootstrap = SquidStdBootstrap.Create(
new SquidStdOptions { ConfigName = "plugins-sample", RootDirectory = Directory.GetCurrentDirectory() }
);

var plugin = new WeatherPlugin();
Console.WriteLine($"Loading {plugin.Metadata.Name} v{plugin.Metadata.Version} by {plugin.Metadata.Author}");
plugin.Configure(container, context);

var greeter = container.Resolve<IGreeter>();
bootstrap.UsePlugins(plugins => plugins.Add(plugin));

var greeter = bootstrap.Resolve<IGreeter>();
Console.WriteLine(greeter.Greet("squid"));

#endregion
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

<ItemGroup>
<ProjectReference Include="..\..\src\SquidStd.Plugin.Abstractions\SquidStd.Plugin.Abstractions.csproj"/>
<ProjectReference Include="..\..\src\SquidStd.Plugin\SquidStd.Plugin.csproj"/>
<ProjectReference Include="..\..\src\SquidStd.Services.Core\SquidStd.Services.Core.csproj"/>
</ItemGroup>

</Project>
7 changes: 7 additions & 0 deletions src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using DryIoc;
using SquidStd.Core.Data.Bootstrap;
using SquidStd.Core.Interfaces.Config;
using SquidStd.Core.Types.Bootstrap;

namespace SquidStd.Core.Interfaces.Bootstrap;

Expand All @@ -19,6 +20,12 @@ public interface ISquidStdBootstrap : IAsyncDisposable
/// </summary>
IContainer Container { get; }

/// <summary>
/// Gets the current lifecycle state of the bootstrap. Reads are lock-free and intended for
/// pre-start guards, not for synchronizing concurrent lifecycle transitions.
/// </summary>
BootstrapStateType State { get; }

/// <summary>
/// Applies custom service registrations before the bootstrap lifecycle starts.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
namespace SquidStd.Services.Core.Types;
namespace SquidStd.Core.Types.Bootstrap;

/// <summary>
/// Lifecycle state of the SquidStd bootstrapper.
/// </summary>
internal enum BootstrapStateType
public enum BootstrapStateType
{
/// <summary>Created but not started.</summary>
Created,
Expand Down
18 changes: 18 additions & 0 deletions src/SquidStd.Plugin.Abstractions/Data/PluginContextKeys.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace SquidStd.Plugin.Abstractions.Data;

/// <summary>
/// Well-known keys the SquidStd plugin loader populates in <see cref="PluginContext.Data" />.
/// </summary>
public static class PluginContextKeys
{
/// <summary>
/// Application root directory (string): the bootstrap RootDirectory option.
/// </summary>
public const string RootDirectory = "squidstd:rootDirectory";

/// <summary>
/// Application name (string): the bootstrap AppName option when set, otherwise the entry
/// assembly name, otherwise the bootstrap ConfigName.
/// </summary>
public const string AppName = "squidstd:appName";
}
4 changes: 2 additions & 2 deletions src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
namespace SquidStd.Plugin.Abstractions.Data;

/// <summary>
/// Describes a Moongate plugin. This is the source of truth for plugin identity.
/// Describes a SquidStd plugin. This is the source of truth for plugin identity.
/// </summary>
public sealed class PluginMetadata
{
/// <summary>Stable lowercase dotted plugin identifier, for example <c>moongate.weather</c>.</summary>
/// <summary>Stable lowercase dotted plugin identifier, for example <c>myapp.weather</c>.</summary>
public required string Id { get; init; }

/// <summary>Human-readable plugin name.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
namespace SquidStd.Plugin.Abstractions.Interfaces.Plugins;

/// <summary>
/// Implemented by trusted .NET plugins loaded by Moongate during server startup.
/// Implemented by trusted .NET plugins loaded by the SquidStd plugin loader during application startup.
/// </summary>
public interface ISquidStdPlugin
{
Expand Down
1 change: 1 addition & 0 deletions src/SquidStd.Plugin.Abstractions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public sealed class MyPlugin : ISquidStdPlugin
## Related

- Tutorial: [Plugins](https://tgiachi.github.io/squid-std/tutorials/plugins.html)
- Loader: these contracts are loaded by the [SquidStd.Plugin](https://tgiachi.github.io/squid-std/articles/plugin.html) package via `UsePlugins`.

## License

Expand Down
15 changes: 15 additions & 0 deletions src/SquidStd.Plugin/Exceptions/PluginLoadException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace SquidStd.Plugin.Exceptions;

/// <summary>
/// Raised when the plugin loader cannot discover, order, or instantiate plugins.
/// </summary>
public class PluginLoadException : Exception
{
/// <summary>Initializes the exception with a message describing the failure.</summary>
public PluginLoadException(string message)
: base(message) { }

/// <summary>Initializes the exception with a message and the underlying cause.</summary>
public PluginLoadException(string message, Exception innerException)
: base(message, innerException) { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System.Reflection;
using Serilog;
using SquidStd.Core.Interfaces.Bootstrap;
using SquidStd.Core.Types.Bootstrap;
using SquidStd.Plugin.Abstractions.Data;
using SquidStd.Plugin.Abstractions.Interfaces.Plugins;
using SquidStd.Plugin.Internal;

namespace SquidStd.Plugin.Extensions;

/// <summary>
/// Connects the plugin loader to the SquidStd bootstrap.
/// </summary>
public static class SquidStdBootstrapPluginExtensions
{
/// <summary>
/// Collects internal plugins and external plugin directories, resolves the dependency order
/// across the whole set, and invokes <see cref="ISquidStdPlugin.Configure" /> for each plugin
/// in order against the bootstrap container. Must be called before the bootstrap starts, so
/// plugins can register configuration sections before the configuration is loaded. Relative
/// directories are resolved against the bootstrap root directory. Any failure aborts startup:
/// loader problems raise <see cref="Exceptions.PluginLoadException" /> and plugin exceptions
/// propagate unchanged. Plugin assemblies load into the default AssemblyLoadContext and are
/// fully trusted: there is no unloading and no version isolation. Note that plugin load
/// logging is emitted before the bootstrap configures Serilog, so it is only visible when
/// a logger is configured beforehand.
/// </summary>
/// <param name="bootstrap">The bootstrap to configure.</param>
/// <param name="configure">Callback that registers plugins and directories.</param>
/// <returns>The same bootstrap for chaining.</returns>
public static ISquidStdBootstrap UsePlugins(
this ISquidStdBootstrap bootstrap,
Action<PluginCollectionBuilder> configure
)
{
ArgumentNullException.ThrowIfNull(bootstrap);
ArgumentNullException.ThrowIfNull(configure);

if (bootstrap.State != BootstrapStateType.Created)
{
throw new InvalidOperationException(
"Plugins must be registered before the bootstrap starts."
);
}

var logger = Log.ForContext(typeof(SquidStdBootstrapPluginExtensions));
var builder = new PluginCollectionBuilder();

configure(builder);

var plugins = new List<ISquidStdPlugin>(builder.Plugins);

foreach (var directory in builder.Directories)
{
var resolved = Path.IsPathRooted(directory)
? directory
: Path.Combine(bootstrap.Options.RootDirectory, directory);

Check notice

Code scanning / CodeQL

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.

plugins.AddRange(PluginAssemblyScanner.Scan(resolved));
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Select Note

This foreach loop immediately
maps its iteration variable to another variable
- consider mapping the sequence explicitly using '.Select(...)'.
Comment on lines +53 to +60

var ordered = PluginDependencyResolver.Sort(plugins);
var appName = !string.IsNullOrWhiteSpace(bootstrap.Options.AppName)
? bootstrap.Options.AppName
: Assembly.GetEntryAssembly()?.GetName().Name ?? bootstrap.Options.ConfigName;

foreach (var plugin in ordered)
{
var context = new PluginContext();
context.Data[PluginContextKeys.RootDirectory] = bootstrap.Options.RootDirectory;
context.Data[PluginContextKeys.AppName] = appName;

plugin.Configure(bootstrap.Container, context);

logger.Information(
"Loaded plugin {PluginId:l} v{PluginVersion} by {PluginAuthor:l}",
plugin.Metadata.Id,
plugin.Metadata.Version,
plugin.Metadata.Author
);
}

logger.Information("{PluginCount} plugin(s) loaded", ordered.Count);

return bootstrap;
}
}
80 changes: 80 additions & 0 deletions src/SquidStd.Plugin/Internal/PluginAssemblyScanner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System.Reflection;
using Serilog;
using SquidStd.Plugin.Abstractions.Interfaces.Plugins;
using SquidStd.Plugin.Exceptions;

namespace SquidStd.Plugin.Internal;

/// <summary>
/// Scans a directory for external plugin assemblies, loads each into the default
/// AssemblyLoadContext, and instantiates every concrete <see cref="ISquidStdPlugin"/> found.
/// </summary>
internal static class PluginAssemblyScanner
{
private static readonly ILogger Logger = Log.ForContext(typeof(PluginAssemblyScanner));

/// <summary>
/// Scans <paramref name="directory"/> for <c>*.dll</c> files and returns every concrete
/// <see cref="ISquidStdPlugin"/> instantiated from them.
/// </summary>
/// <param name="directory">The directory to scan for plugin assemblies.</param>
/// <exception cref="PluginLoadException">
/// The directory does not exist, an assembly failed to load, or a plugin type failed to instantiate.
/// </exception>
public static IReadOnlyList<ISquidStdPlugin> Scan(string directory)
{
if (!Directory.Exists(directory))
{
throw new PluginLoadException($"Plugin directory '{directory}' does not exist.");
}

var plugins = new List<ISquidStdPlugin>();
var dllPaths = Directory.EnumerateFiles(directory, "*.dll")
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase);

foreach (var dllPath in dllPaths)
{
List<Type> pluginTypes;

try
{
// Default AssemblyLoadContext: plugins are trusted and share type identity
// with the host (ISquidStdPlugin, DryIoc). No unload, no version isolation.
var assembly = Assembly.LoadFrom(dllPath);

pluginTypes = assembly.GetTypes()
.Where(type => typeof(ISquidStdPlugin).IsAssignableFrom(type) &&
type is { IsClass: true, IsAbstract: false })
.ToList();
}
catch (Exception ex)
{
throw new PluginLoadException($"Failed to load plugin assembly '{dllPath}'.", ex);
}

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.
Comment on lines +50 to +53

if (pluginTypes.Count == 0)
{
Logger.Debug("No plugins found in {AssemblyPath:l}", dllPath);

continue;
}

foreach (var pluginType in pluginTypes)
{
try
{
plugins.Add((ISquidStdPlugin)Activator.CreateInstance(pluginType)!);
}
catch (Exception ex)
{
throw new PluginLoadException(
$"Failed to instantiate plugin type '{pluginType.FullName}' from '{dllPath}'.",
ex
);
}

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.
Comment on lines +68 to +74
}
}

return plugins;
}
}
Loading
Loading