Init
This commit is contained in:
commit
e70a04ac16
19 changed files with 576 additions and 0 deletions
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
|
@ -0,0 +1,22 @@
|
|||
# This file is for zig-specific build artifacts.
|
||||
# If you have OS-specific or editor-specific files to ignore,
|
||||
# such as *.swp or .DS_Store, put those in your global
|
||||
# ~/.gitignore and put this in your ~/.gitconfig:
|
||||
#
|
||||
# [core]
|
||||
# excludesfile = ~/.gitignore
|
||||
#
|
||||
# Cheers!
|
||||
# -andrewrk
|
||||
|
||||
.zig-cache/
|
||||
zig-out/
|
||||
/release/
|
||||
/debug/
|
||||
/build/
|
||||
/build-*/
|
||||
/docgen_tmp/
|
||||
|
||||
# Although this was renamed to .zig-cache, let's leave it here for a few
|
||||
# releases to make it less annoying to work with multiple branches.
|
||||
zig-cache/
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Dominic Grimm
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
35
build.zig
Normal file
35
build.zig
Normal file
|
@ -0,0 +1,35 @@
|
|||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
const zap_dep = b.dependency("zap", .{});
|
||||
|
||||
const lib_mod = b.addModule("craftflut", .{
|
||||
.root_source_file = b.path("src/root.zig"),
|
||||
});
|
||||
lib_mod.addImport("zap", zap_dep.module("zap"));
|
||||
|
||||
const exe_mod = b.createModule(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
exe_mod.addImport("craftflut", lib_mod);
|
||||
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "craftflut",
|
||||
.root_module = exe_mod,
|
||||
});
|
||||
b.installArtifact(exe);
|
||||
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
run_cmd.step.dependOn(b.getInstallStep());
|
||||
if (b.args) |args| {
|
||||
run_cmd.addArgs(args);
|
||||
}
|
||||
|
||||
const run_step = b.step("run", "Run the app");
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
}
|
19
build.zig.zon
Normal file
19
build.zig.zon
Normal file
|
@ -0,0 +1,19 @@
|
|||
.{
|
||||
.name = .craftflut,
|
||||
.version = "0.0.0",
|
||||
.fingerprint = 0xc80c703678f7ec11,
|
||||
.minimum_zig_version = "0.14.0",
|
||||
|
||||
.dependencies = .{
|
||||
.zap = .{
|
||||
.url = "git+https://github.com/zigzap/zap.git#ec7cac6f6ab8e1892fe6fc499fd37cd93f7b2256",
|
||||
.hash = "zap-0.9.1-GoeB85JTJAADY1vAnA4lTuU66t6JJiuhGos5ex6CpifA",
|
||||
},
|
||||
},
|
||||
.paths = .{
|
||||
"build.zig",
|
||||
"build.zig.zon",
|
||||
"src",
|
||||
"LICENSE",
|
||||
},
|
||||
}
|
0
src/dispatchers/BlockUpdate.zig
Normal file
0
src/dispatchers/BlockUpdate.zig
Normal file
0
src/dispatchers/root.zig
Normal file
0
src/dispatchers/root.zig
Normal file
24
src/main.zig
Normal file
24
src/main.zig
Normal file
|
@ -0,0 +1,24 @@
|
|||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const craftflut = @import("craftflut");
|
||||
|
||||
pub const std_options: std.Options = .{
|
||||
.log_level = if (builtin.mode == .Debug) .debug else .info,
|
||||
};
|
||||
|
||||
pub fn main() !void {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{
|
||||
.thread_safe = true,
|
||||
}){};
|
||||
defer _ = gpa.deinit();
|
||||
const allocator = gpa.allocator();
|
||||
|
||||
{
|
||||
const config = craftflut.Config{
|
||||
.web_port = 3000,
|
||||
.web_threads = 16,
|
||||
};
|
||||
try craftflut.start(allocator, &config);
|
||||
}
|
||||
}
|
152
src/mpsc.zig
Normal file
152
src/mpsc.zig
Normal file
|
@ -0,0 +1,152 @@
|
|||
const std = @import("std");
|
||||
|
||||
pub fn Slot(comptime T: type) type {
|
||||
return struct {
|
||||
value: T,
|
||||
version: std.atomic.Value(usize),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn RingBuffer(comptime T: type) type {
|
||||
return struct {
|
||||
const Self = @This();
|
||||
|
||||
capacity: usize,
|
||||
buffer: []Slot(T),
|
||||
head: std.atomic.Value(usize) = std.atomic.Value(usize).init(0),
|
||||
tail: usize = 0,
|
||||
|
||||
pub fn init(buffer: []Slot(T)) Self {
|
||||
// var buffer: [capacity]Slot(T) = undefined;
|
||||
// {
|
||||
// @setEvalBranchQuota(capacity * 4);
|
||||
// for (0..capacity) |i| {
|
||||
// buffer[i].version = std.atomic.Value(usize).init(i);
|
||||
// }
|
||||
// }
|
||||
for (buffer, 0..) |*slot, i| {
|
||||
slot.version = std.atomic.Value(usize).init(i);
|
||||
}
|
||||
|
||||
return .{
|
||||
.capacity = buffer.len,
|
||||
.buffer = buffer,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn enqueue(self: *Self, value: T) error{Overflow}!void {
|
||||
while (true) {
|
||||
const head = self.head.load(.acquire);
|
||||
const index = head % self.capacity;
|
||||
const slot = &self.buffer[index];
|
||||
|
||||
const expected_version = head;
|
||||
if (slot.version.load(.acquire) != expected_version) {
|
||||
return error.Overflow;
|
||||
}
|
||||
|
||||
if (self.head.cmpxchgStrong(
|
||||
head,
|
||||
head + 1,
|
||||
.seq_cst,
|
||||
.seq_cst,
|
||||
)) |_| {
|
||||
std.atomic.spinLoopHint();
|
||||
continue;
|
||||
}
|
||||
|
||||
slot.value = value;
|
||||
slot.version.store(expected_version + 1, .release);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dequeue(self: *Self) error{Underflow}!T {
|
||||
const tail = self.tail;
|
||||
const index = tail % self.capacity;
|
||||
const slot = &self.buffer[index];
|
||||
|
||||
const expected_version = tail + 1;
|
||||
if (slot.version.load(.acquire) != expected_version) {
|
||||
return error.Underflow;
|
||||
}
|
||||
|
||||
const value = slot.value;
|
||||
slot.version.store(tail +% self.capacity, .release);
|
||||
self.tail +%= 1;
|
||||
return value;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// pub fn RingBuffer(comptime T: type) type {
|
||||
// return struct {
|
||||
// const Self = @This();
|
||||
|
||||
// capacity: usize,
|
||||
// buffer: []Slot(T),
|
||||
// head: std.atomic.Value(usize) = std.atomic.Value(usize).init(0),
|
||||
// tail: usize = 0,
|
||||
|
||||
// pub fn init(buffer: []Slot(T)) Self {
|
||||
// // var buffer: [capacity]Slot(T) = undefined;
|
||||
// // {
|
||||
// // @setEvalBranchQuota(capacity * 4);
|
||||
// // for (0..capacity) |i| {
|
||||
// // buffer[i].version = std.atomic.Value(usize).init(i);
|
||||
// // }
|
||||
// // }
|
||||
// for (buffer, 0..) |*slot, i| {
|
||||
// slot.value = std.atomic.Value(usize).init(i);
|
||||
// }
|
||||
|
||||
// return .{
|
||||
// .capacity = buffer.len,
|
||||
// .buffer = buffer,
|
||||
// };
|
||||
// }
|
||||
|
||||
// pub fn enqueue(self: *Self, value: T) error{Overflow}!void {
|
||||
// while (true) {
|
||||
// const head = self.head.load(.acquire);
|
||||
// const index = head % self.capacity;
|
||||
// const slot = &self.buffer[index];
|
||||
|
||||
// const expected_version = head;
|
||||
// if (slot.version.load(.acquire) != expected_version) {
|
||||
// return error.Overflow;
|
||||
// }
|
||||
|
||||
// if (self.head.cmpxchgStrong(
|
||||
// head,
|
||||
// head + 1,
|
||||
// .seq_cst,
|
||||
// .seq_cst,
|
||||
// )) |_| {
|
||||
// std.atomic.spinLoopHint();
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// slot.value = value;
|
||||
// slot.version.store(expected_version + 1, .release);
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
|
||||
// pub fn dequeue(self: *Self) error{Underflow}!T {
|
||||
// const tail = self.tail;
|
||||
// const index = tail % self.capacity;
|
||||
// const slot = &self.buffer[index];
|
||||
|
||||
// const expected_version = tail + 1;
|
||||
// if (slot.version.load(.acquire) != expected_version) {
|
||||
// return error.Underflow;
|
||||
// }
|
||||
|
||||
// const value = slot.value;
|
||||
// slot.version.store(tail +% self.capacity, .release);
|
||||
// self.tail +%= 1;
|
||||
// return value;
|
||||
// }
|
||||
// };
|
||||
// }
|
10
src/msg_queue/messages.zig
Normal file
10
src/msg_queue/messages.zig
Normal file
|
@ -0,0 +1,10 @@
|
|||
pub const BlockUpdate = struct {
|
||||
pub const material_len_max = 32;
|
||||
|
||||
dimension: u8,
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
material: [material_len_max]u8 = undefined,
|
||||
material_len: usize = 0,
|
||||
};
|
35
src/msg_queue/queue.zig
Normal file
35
src/msg_queue/queue.zig
Normal file
|
@ -0,0 +1,35 @@
|
|||
const std = @import("std");
|
||||
|
||||
const mpsc = @import("../mpsc.zig");
|
||||
|
||||
pub fn Queue(comptime T: type) type {
|
||||
return struct {
|
||||
const Self = @This();
|
||||
|
||||
ring_buffer: mpsc.RingBuffer(T),
|
||||
|
||||
pub fn init(buffer: []mpsc.Slot(T)) Self {
|
||||
return .{
|
||||
.ring_buffer = mpsc.RingBuffer(T).init(buffer),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn blockingEnqueue(self: *Self, value: T) void {
|
||||
while (true) {
|
||||
self.ring_buffer.enqueue(value) catch |err| switch (err) {
|
||||
error.Overflow => {
|
||||
std.atomic.spinLoopHint();
|
||||
continue;
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dequeue(self: *Self) ?T {
|
||||
return self.ring_buffer.dequeue() catch |err| switch (err) {
|
||||
error.Underflow => return null,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
78
src/msg_queue/root.zig
Normal file
78
src/msg_queue/root.zig
Normal file
|
@ -0,0 +1,78 @@
|
|||
const std = @import("std");
|
||||
|
||||
const mpsc = @import("../mpsc.zig");
|
||||
const queue = @import("queue.zig");
|
||||
|
||||
pub const messages = @import("messages.zig");
|
||||
|
||||
pub fn MsgQueueUnmanaged(comptime T: type) type {
|
||||
return struct {
|
||||
const Self = @This();
|
||||
|
||||
queue: queue.Queue(T),
|
||||
// consumer_mutex: std.Thread.Mutex = std.Thread.Mutex{},
|
||||
cond: std.Thread.Condition = std.Thread.Condition{},
|
||||
|
||||
pub fn init(buffer: []mpsc.Slot(T)) Self {
|
||||
return .{
|
||||
.queue = queue.Queue(T).init(buffer),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn enqueue(self: *Self, value: T) void {
|
||||
self.queue.blockingEnqueue(value);
|
||||
std.log.info("enqueue: SENDING signal", .{});
|
||||
self.cond.signal();
|
||||
}
|
||||
|
||||
pub fn dequeue(self: *Self) T {
|
||||
var m = std.Thread.Mutex{};
|
||||
m.lock();
|
||||
defer m.unlock();
|
||||
|
||||
while (true) {
|
||||
if (self.queue.dequeue()) |val| return val;
|
||||
std.log.info("dequeue: STARTING consumer condition wait", .{});
|
||||
self.cond.wait(&m);
|
||||
std.log.info("dequeue: RECEIVED signal", .{});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub fn MsgQueueManaged(comptime T: type) type {
|
||||
return struct {
|
||||
const Self = @This();
|
||||
|
||||
allocator: std.mem.Allocator,
|
||||
buffer: []mpsc.Slot(T),
|
||||
msg_queue: MsgQueueUnmanaged(T),
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator, capacity: usize) !Self {
|
||||
const buffer = try allocator.alloc(mpsc.Slot(T), capacity);
|
||||
errdefer allocator.free(buffer);
|
||||
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.buffer = buffer,
|
||||
.msg_queue = MsgQueueUnmanaged(T).init(buffer),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
self.allocator.free(self.buffer);
|
||||
}
|
||||
|
||||
pub fn unmanaged(self: *Self) *MsgQueueUnmanaged(T) {
|
||||
return &self.msg_queue;
|
||||
}
|
||||
|
||||
pub fn enqueue(self: *Self, value: T) void {
|
||||
self.msg_queue.enqueue(value);
|
||||
}
|
||||
|
||||
pub fn dequeue(self: *Self) T {
|
||||
return self.msg_queue.dequeue();
|
||||
}
|
||||
};
|
||||
}
|
28
src/root.zig
Normal file
28
src/root.zig
Normal file
|
@ -0,0 +1,28 @@
|
|||
const std = @import("std");
|
||||
|
||||
const msg_queue = @import("msg_queue/root.zig");
|
||||
const dispatchers = @import("dispatchers/root.zig");
|
||||
const web = @import("web/root.zig");
|
||||
|
||||
pub const Config = struct {
|
||||
web_port: u16,
|
||||
web_threads: i16,
|
||||
};
|
||||
|
||||
fn receiver(queue: *msg_queue.MsgQueueUnmanaged(msg_queue.messages.BlockUpdate)) void {
|
||||
while (true) {
|
||||
const packet = queue.dequeue();
|
||||
std.log.info("packet: {}", .{packet});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(allocator: std.mem.Allocator, config: *const Config) !void {
|
||||
// var update_queue = UpdateQueue.init();
|
||||
const capacity: usize = 1024;
|
||||
var queue = try msg_queue.MsgQueueManaged(msg_queue.messages.BlockUpdate).init(allocator, capacity);
|
||||
defer queue.deinit();
|
||||
|
||||
_ = try std.Thread.spawn(.{}, receiver, .{queue.unmanaged()});
|
||||
|
||||
try web.start(allocator, config.web_threads, config.web_port, queue.unmanaged());
|
||||
}
|
89
src/web/endpoints/Block.zig
Normal file
89
src/web/endpoints/Block.zig
Normal file
|
@ -0,0 +1,89 @@
|
|||
const std = @import("std");
|
||||
const zap = @import("zap");
|
||||
|
||||
const models = @import("../models/root.zig");
|
||||
const stores = @import("../stores/root.zig");
|
||||
const msg_queue = @import("../../msg_queue/root.zig");
|
||||
|
||||
const Self = @This();
|
||||
|
||||
pub const default_path = "block";
|
||||
|
||||
allocator: std.mem.Allocator,
|
||||
block_update_queue: *const stores.BlockUpdateQueue,
|
||||
|
||||
path: []const u8,
|
||||
error_strategy: zap.Endpoint.ErrorStrategy = .log_to_console,
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator, block_update_queue: *const stores.BlockUpdateQueue, path: []const u8) Self {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.block_update_queue = block_update_queue,
|
||||
.path = path,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get(_: *Self, r: zap.Request) !void {
|
||||
r.setStatus(.method_not_allowed);
|
||||
r.markAsFinished(true);
|
||||
}
|
||||
|
||||
pub fn post(_: *Self, r: zap.Request) !void {
|
||||
r.setStatus(.method_not_allowed);
|
||||
r.markAsFinished(true);
|
||||
}
|
||||
|
||||
pub fn put(self: *Self, r: zap.Request) !void {
|
||||
blk: {
|
||||
if (r.body) |body| {
|
||||
const maybe_block: ?std.json.Parsed(models.Block) = std.json.parseFromSlice(models.Block, self.allocator, body, .{}) catch null;
|
||||
if (maybe_block) |parsed| {
|
||||
defer parsed.deinit();
|
||||
const block = parsed.value;
|
||||
if (block.material.len > msg_queue.messages.BlockUpdate.material_len_max or !models.Block.materialIsValid(block.material)) {
|
||||
break :blk;
|
||||
}
|
||||
std.log.info("block: {}", .{block});
|
||||
|
||||
var msg = msg_queue.messages.BlockUpdate{
|
||||
.dimension = block.dimension,
|
||||
.x = block.x,
|
||||
.y = block.y,
|
||||
.z = block.z,
|
||||
};
|
||||
std.mem.copyForwards(u8, &msg.material, block.material);
|
||||
msg.material_len = block.material.len;
|
||||
self.block_update_queue.queue.enqueue(msg);
|
||||
|
||||
r.setStatus(.created);
|
||||
r.markAsFinished(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.setStatus(.bad_request);
|
||||
r.markAsFinished(true);
|
||||
}
|
||||
|
||||
pub fn delete(_: *Self, r: zap.Request) !void {
|
||||
r.setStatus(.method_not_allowed);
|
||||
r.markAsFinished(true);
|
||||
}
|
||||
|
||||
pub fn patch(_: *Self, r: zap.Request) !void {
|
||||
r.setStatus(.method_not_allowed);
|
||||
r.markAsFinished(true);
|
||||
}
|
||||
|
||||
pub fn options(_: *Self, r: zap.Request) !void {
|
||||
try r.setHeader("Access-Control-Allow-Origin", "*");
|
||||
try r.setHeader("Access-Control-Allow-Methods", "PUT, OPTIONS, HEAD");
|
||||
r.setStatus(zap.http.StatusCode.no_content);
|
||||
r.markAsFinished(true);
|
||||
}
|
||||
|
||||
pub fn head(_: *Self, r: zap.Request) !void {
|
||||
r.setStatus(zap.http.StatusCode.no_content);
|
||||
r.markAsFinished(true);
|
||||
}
|
1
src/web/endpoints/root.zig
Normal file
1
src/web/endpoints/root.zig
Normal file
|
@ -0,0 +1 @@
|
|||
pub const Block = @import("Block.zig");
|
16
src/web/models/Block.zig
Normal file
16
src/web/models/Block.zig
Normal file
|
@ -0,0 +1,16 @@
|
|||
const std = @import("std");
|
||||
|
||||
dimension: u8,
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
material: []const u8,
|
||||
|
||||
pub fn materialIsValid(material: []const u8) bool {
|
||||
for (material) |ch| {
|
||||
if (!std.ascii.isAlphabetic(ch))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
1
src/web/models/root.zig
Normal file
1
src/web/models/root.zig
Normal file
|
@ -0,0 +1 @@
|
|||
pub const Block = @import("Block.zig");
|
41
src/web/root.zig
Normal file
41
src/web/root.zig
Normal file
|
@ -0,0 +1,41 @@
|
|||
const std = @import("std");
|
||||
const zap = @import("zap");
|
||||
|
||||
const msg_queue = @import("../msg_queue/root.zig");
|
||||
|
||||
const models = @import("models/root.zig");
|
||||
const endpoints = @import("endpoints/root.zig");
|
||||
const stores = @import("stores/root.zig");
|
||||
|
||||
fn onRequest(r: zap.Request) !void {
|
||||
_ = r;
|
||||
}
|
||||
|
||||
pub fn start(allocator: std.mem.Allocator, threads: i16, port: u16, queue: *msg_queue.MsgQueueUnmanaged(msg_queue.messages.BlockUpdate)) !void {
|
||||
var listener = zap.Endpoint.Listener.init(allocator, .{
|
||||
.port = port,
|
||||
.on_request = onRequest,
|
||||
.log = true,
|
||||
});
|
||||
defer listener.deinit();
|
||||
|
||||
const block_update_queue_store = stores.BlockUpdateQueue{
|
||||
.queue = queue,
|
||||
};
|
||||
|
||||
var block_endpoint = endpoints.Block.init(
|
||||
allocator,
|
||||
&block_update_queue_store,
|
||||
std.fmt.comptimePrint("/api/{s}", .{endpoints.Block.default_path}),
|
||||
);
|
||||
|
||||
try listener.register(&block_endpoint);
|
||||
|
||||
try listener.listen();
|
||||
std.log.info("Listening on 0.0.0.0:{d}", .{port});
|
||||
|
||||
zap.start(.{
|
||||
.threads = threads,
|
||||
.workers = 1,
|
||||
});
|
||||
}
|
3
src/web/stores/BlockUpdateQueue.zig
Normal file
3
src/web/stores/BlockUpdateQueue.zig
Normal file
|
@ -0,0 +1,3 @@
|
|||
const msg_queue = @import("../../msg_queue/root.zig");
|
||||
|
||||
queue: *msg_queue.MsgQueueUnmanaged(msg_queue.messages.BlockUpdate),
|
1
src/web/stores/root.zig
Normal file
1
src/web/stores/root.zig
Normal file
|
@ -0,0 +1 @@
|
|||
pub const BlockUpdateQueue = @import("BlockUpdateQueue.zig");
|
Loading…
Add table
Add a link
Reference in a new issue