Run Your Own Campfire on .NET with LINQPad
This post is part of the Campfire series. The last one ran the C# port in Docker, which is how you’d run it for real. This one runs it inside a LINQPad script, which is how I like to poke at an app: the server, the database and your own code in one window.
Campfire’s C# server is ordinary .NET. Its entry point builds an ASP.NET Core WebApplication from a settings object, and nothing about that needs a container. So a LINQPad script can reference the server’s assemblies, build the same app the campfire server command builds, and start it.
What you need
- Windows, with LINQPad 9.
- The .NET 10 SDK, to build the server.
- Git.
Build the server and the frontend
Clone the repository, and check out the Rails reference it was ported from. The frontend’s JavaScript and CSS come from there:
git clone --branch v1.0.0 https://github.com/ryanrodemoyer/once-campfire-csharp.git C:\code\once-campfire-csharp
cd C:\code\once-campfire-csharp
git submodule update --init reference
Build the frontend assets. This writes the digested JavaScript, CSS and import map to artifacts\assets, with no Ruby or Node involved:
dotnet run assets/build-assets.cs -- $PWD.Path artifacts/assets
Then build the server into a folder the script can reference:
dotnet build src/Campfire.Server -c Release -o C:\campfire-bin
The script
Create a new query in LINQPad and set its language to C# Statements. Open Query Properties (F4) and:
- Add references to
campfire.dll,Campfire.Web.dll,Campfire.Data.dllandMicrosoft.Data.Sqlite.dllfromC:\campfire-bin. - Tick “Reference ASP.NET Core”.
- Add the namespaces
Campfire.Server.Cli,Campfire.Web.Assets,Microsoft.Data.Sqlite,Microsoft.Extensions.Logging,System.Text.RegularExpressionsandSystem.Threading.Tasks.
Then paste this:
// Where you cloned the repo, and where this Campfire keeps its data between runs.
var repo = @"C:\code\once-campfire-csharp";
var site = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "campfire-linqpad");
Directory.CreateDirectory(site);
var settings = new ServerSettings
{
Root = site, // the database goes in storage/db/production.sqlite3 under here
SecretKeyBaseDummy = true, // make a secret once and keep it in tmp/local_secret.txt
Ssl = false, // plain HTTP on localhost
AssetsPath = Path.Combine(repo, "artifacts", "assets"),
LogLevel = LogLevel.Warning,
};
var secret = SecretKeyBase.Resolve(settings);
var assets = AssetBundle.Load(settings.AssetsDirectory);
var database = Commands.PrepareDatabase(settings, settings.MaxThreads);
try
{
await using var app = ServerCommand.Build(settings, secret, database, assets, ["--urls", "http://127.0.0.1:3005"]);
await app.StartAsync();
new Hyperlinq("http://127.0.0.1:3005", "Open Campfire").Dump();
var live = new DumpContainer().Dump("Latest messages");
using var sqlite = new SqliteConnection($"Data Source={settings.DatabasePath};Mode=ReadOnly;Pooling=False");
sqlite.Open();
while (!QueryCancelToken.IsCancellationRequested)
{
live.Content = LatestMessages(sqlite);
try { await Task.Delay(1000, QueryCancelToken); } catch (OperationCanceledException) { }
}
await app.StopAsync();
}
finally
{
Commands.Close(database);
}
static List<object> LatestMessages(SqliteConnection sqlite)
{
using var command = sqlite.CreateCommand();
command.CommandText = """
SELECT messages.created_at, rooms.name, users.name, action_text_rich_texts.body
FROM messages
JOIN users ON users.id = messages.creator_id
JOIN rooms ON rooms.id = messages.room_id
LEFT JOIN action_text_rich_texts
ON action_text_rich_texts.record_type = 'Message'
AND action_text_rich_texts.record_id = messages.id
AND action_text_rich_texts.name = 'body'
ORDER BY messages.id DESC
LIMIT 20
""";
using var reader = command.ExecuteReader();
var rows = new List<object>();
while (reader.Read())
{
rows.Add(new
{
At = reader.GetString(0),
Room = reader.IsDBNull(1) ? "(direct)" : reader.GetString(1),
From = reader.GetString(2),
Text = reader.IsDBNull(3) ? "" : Regex.Replace(reader.GetString(3), "<[^>]+>", ""),
});
}
return rows;
}
Change repo if you cloned somewhere else, and press F5.
What happens
The script does what the campfire server command does in the Docker image, one step at a time:
SecretKeyBase.Resolvegets the secret that signs and encrypts cookies. WithSecretKeyBaseDummy, it makes one the first time and keeps it under the site folder, so you stay signed in between runs.AssetBundle.Loadreads the frontend you built.Commands.PrepareDatabaseopens the SQLite database, asbin/rails db:preparedoes: it creates the file and loads the schema the first time, refuses a database with migrations missing, and opens the writer and its pool of readers.ServerCommand.Buildbuilds the ASP.NET Core app with Campfire’s routes, andStartAsyncstarts Kestrel on port 3005.
Click “Open Campfire”. The first visit walks you through creating the admin account, and then you’re in the “All Talk” room.
Back in LINQPad, the “Latest messages” panel is a DumpContainer, and the loop replaces its content every second with the 20 newest messages, read straight from the same SQLite file the server writes. Type in the browser and watch the rows appear. A message’s text lives in Action Text’s action_text_rich_texts table as HTML, so the query joins it in and strips the tags for display. The read-only connection is safe next to the running server because the database is in WAL mode, which lets readers and the writer work at the same time.
Press Stop (Shift+F5) and the loop ends, the server stops and the database closes. Your rooms and messages stay in %LOCALAPPDATA%\campfire-linqpad, so the next run picks up where you left off. Delete that folder to start over.
What’s different from the Docker image
This is a development setup, not a way to host Campfire for other people. Compared with the image:
- The script serves plain HTTP on localhost, and there’s no Thruster in front for TLS or certificates.
- There are no image previews. Campfire uses libvips to read the size of uploaded images and make previews. The port only loads libvips on Linux, so on Windows it can’t read an image’s size or make a preview.
- There’s no background WAL checkpoint. The
campfire servercommand also starts a background checkpoint, which takes a lock withflockfrom libc. That doesn’t exist on Windows, so the script callsServerCommand.Builddirectly and skips it. The writer’s own checkpoint from post 1 still runs, so the WAL stays bounded.
Things to try
Having the server and your code in one process makes some things easy:
- Point a second
DumpContaineratusersorroomsand watch people and rooms appear as you create them. - Use
HttpClientin the same script to sign in and fetch pages, and.Dump()the HTML. - Copy one of the snippets from earlier in this series into the script and measure the real app instead of a toy one.
If something behaves differently from Rails, that’s a bug in the port, and I’d like to hear about it: open an issue.