This commit is contained in:
Bob Farrell 2025-05-02 19:57:49 +01:00
parent e647b0057a
commit b795c6184e
4 changed files with 272 additions and 179 deletions

View File

@ -4,6 +4,13 @@ const httpz = @import("httpz");
const jetzig = @import("../../jetzig.zig"); const jetzig = @import("../../jetzig.zig");
pub const Env = struct {
store: *jetzig.kv.Store.GeneralStore,
cache: *jetzig.kv.Store.CacheStore,
job_queue: *jetzig.kv.Store.JobQueueStore,
logger: jetzig.loggers.Logger,
};
pub fn RoutedChannel(Routes: type) type { pub fn RoutedChannel(Routes: type) type {
return struct { return struct {
const Channel = @This(); const Channel = @This();
@ -12,6 +19,32 @@ pub fn RoutedChannel(Routes: type) type {
websocket: *jetzig.websockets.RoutedWebsocket(Routes), websocket: *jetzig.websockets.RoutedWebsocket(Routes),
state: *jetzig.data.Value, state: *jetzig.data.Value,
data: *jetzig.data.Data, data: *jetzig.data.Data,
_connections: *std.StringHashMap(Connection),
env: Env,
const Connection = struct {
object: *jetzig.data.Value,
data: *jetzig.Data,
};
pub fn init(allocator: std.mem.Allocator, websocket: *jetzig.websockets.Websocket) !Channel {
const connections = try allocator.create(std.StringHashMap(Connection));
connections.* = std.StringHashMap(Connection).init(allocator);
return .{
.allocator = allocator,
.websocket = websocket,
.state = try websocket.getState(),
.data = websocket.data,
._connections = connections,
.env = .{
.store = websocket.store,
.cache = websocket.cache,
.job_queue = websocket.job_queue,
.logger = websocket.logger,
},
};
}
pub fn publish(channel: Channel, data: anytype) !void { pub fn publish(channel: Channel, data: anytype) !void {
var stack_fallback = std.heap.stackFallback(4096, channel.allocator); var stack_fallback = std.heap.stackFallback(4096, channel.allocator);
@ -23,7 +56,7 @@ pub fn RoutedChannel(Routes: type) type {
const writer = write_buffer.writer(); const writer = write_buffer.writer();
try std.json.stringify(data, .{}, writer); try std.json.stringify(data, .{}, writer);
try write_buffer.flush(); try write_buffer.flush();
channel.websocket.logger.DEBUG( channel.env.logger.DEBUG(
"Published Channel message for `{s}`", "Published Channel message for `{s}`",
.{channel.websocket.route.path}, .{channel.websocket.route.path},
) catch {}; ) catch {};
@ -45,12 +78,48 @@ pub fn RoutedChannel(Routes: type) type {
try writer.writeAll("__jetzig_event__:"); try writer.writeAll("__jetzig_event__:");
try std.json.stringify(.{ .method = method, .params = args }, .{}, writer); try std.json.stringify(.{ .method = method, .params = args }, .{}, writer);
try write_buffer.flush(); try write_buffer.flush();
channel.websocket.logger.DEBUG( channel.env.logger.DEBUG(
"Invoked Javascript function `{s}` for `{s}`", "Invoked Javascript function `{s}` for `{s}`",
.{ @tagName(method), channel.websocket.route.path }, .{ @tagName(method), channel.websocket.route.path },
) catch {}; ) catch {};
} }
pub fn connect(channel: Channel, comptime scope: []const u8) !*jetzig.data.Value {
if (channel._connections.get(scope)) |cached| {
return cached.object;
}
if (channel.websocket.session_id.len != 32) return error.JetzigInvalidSessionIdLength;
const connections = channel.get("_connections") orelse try channel.put("_connections", .array);
const connection_id = for (connections.items(.array)) |connection| {
if (connection.getT(.string, "scope")) |connection_scope| {
if (std.mem.eql(u8, connection_scope, scope)) {
break connection.getT(.string, "id") orelse return error.JetzigInvalidChannelState;
}
}
} else blk: {
const id = try channel.allocator.alloc(u8, 32);
_ = jetzig.util.generateRandomString(id);
try connections.append(.{ .id = id, .scope = scope });
break :blk id;
};
var buf: [32 + ":".len + 32]u8 = undefined;
const connection_key = try std.fmt.bufPrint(&buf, "{s}:{s}", .{ channel.websocket.session_id, connection_id });
return try channel.websocket.channels.get(channel.data, connection_key) orelse blk: {
const data = try channel.allocator.create(jetzig.Data);
data.* = jetzig.Data.init(channel.allocator);
const object = try data.root(.object);
const duped_connection_key = try channel.allocator.dupe(u8, connection_key);
try channel.websocket.channels.put(duped_connection_key, object);
try channel._connections.put(scope, .{ .data = data, .object = object });
const connections_state = channel.get("_connections_state") orelse try channel.put("_connections_state", .object);
try connections_state.put(scope, object);
break :blk object;
};
}
pub fn getT( pub fn getT(
channel: Channel, channel: Channel,
comptime T: jetzig.data.Data.ValueType, comptime T: jetzig.data.Data.ValueType,
@ -76,7 +145,13 @@ pub fn RoutedChannel(Routes: type) type {
} }
pub fn sync(channel: Channel) !void { pub fn sync(channel: Channel) !void {
try channel.websocket.syncState(channel); try channel.websocket.syncState(channel.data, "__root__");
var it = channel._connections.iterator();
while (it.next()) |entry| {
const data = entry.value_ptr.*.data;
const scope = entry.key_ptr.*;
try channel.websocket.syncState(data, scope);
}
} }
}; };
} }

View File

@ -225,6 +225,9 @@ pub fn RoutedServer(Routes: type) type {
.route = route, .route = route,
.session_id = session_id, .session_id = session_id,
.channels = self.channels, .channels = self.channels,
.store = self.store,
.cache = self.cache,
.job_queue = self.job_queue,
.logger = self.logger, .logger = self.logger,
}, },
); );

View File

@ -2,175 +2,189 @@ window.jetzig = window.jetzig ? window.jetzig : {}
jetzig = window.jetzig; jetzig = window.jetzig;
(() => { (() => {
const state_tag = "__jetzig_channel_state__:";
const actions_tag = "__jetzig_actions__:";
const event_tag = "__jetzig_event__:";
const transform = (value, state, element) => { const transform = (value, state, element) => {
const id = element.getAttribute('jetzig-id'); const id = element.getAttribute('jetzig-id');
const transformer = id && jetzig.channel.transformers[id]; const transformer = id && jetzig.channel.transformers[id];
if (transformer) { if (transformer) {
return transformer(value, state, element); return transformer(value, state, element);
} else {
return value === undefined || value == null ? '' : `${value}`
}
};
const reduceState = (ref, state) => {
if (!ref.startsWith('$.')) throw new Error(`Unexpected ref format: ${ref}`);
const args = ref.split('.');
args.shift();
const isNumeric = (string) => [...string].every(char => '0123456789'.includes(char));
const isObject = (object) => object && typeof object === 'object';
return args.reduce((acc, arg) => {
if (isNumeric(arg)) {
if (acc && Array.isArray(acc) && acc.length > arg) return acc[parseInt(arg)];
return null;
} else { } else {
return value === undefined || value == null ? '' : `${value}` if (acc && isObject(acc)) return acc[arg];
return null;
} }
}, state);
};
const handleState = (event, channel) => {
// TODO: Handle different scopes and update elements based on scope.
const detagged = event.data.slice(state_tag.length);
const scope = detagged.split(':', 1)[0];
const state = JSON.parse(detagged.slice(scope.length + 1));
Object.entries(channel.elementMap).forEach(([ref, elements]) => {
const value = reduceState(ref, state);
elements.forEach(element => element.innerHTML = transform(value, state, element));
});
channel.stateChangedCallbacks.forEach((callback) => {
callback(state);
});
};
const handleEvent = (event, channel) => {
const data = JSON.parse(event.data.slice(event_tag.length));
if (Object.hasOwn(channel.invokeCallbacks, data.method)) {
channel.invokeCallbacks[data.method].forEach(callback => {
callback(data);
});
}
};
const handleAction = (event, channel) => {
const data = JSON.parse(event.data.slice(actions_tag.length));
data.actions.forEach(action => {
channel.action_specs[action.name] = {
callback: (...params) => {
if (action.params.length != params.length) {
throw new Error(`Invalid params for action '${action.name}'. Expected ${action.params.length} params, found ${params.length}`);
}
[...action.params].forEach((param, index) => {
if (param.type !== typeof params[index]) {
const err = `Incorrect argument type for argument ${index} in '${action.name}'. Expected: ${param.type}, found ${typeof params[index]}`;
switch (param.type) {
case "string":
params[index] = `${params[index]}`;
break;
case "integer":
try { params[index] = parseInt(params[index]) } catch {
throw new Error(err);
};
break;
case "float":
try { params[index] = parseFloat(params[index]) } catch {
throw new Error(err);
};
case "boolean":
params[index] = ["true", "y", "1", "yes", "t"].includes(params[index]);
break;
default:
throw new Error(err);
}
}
});
channel.websocket.send(`_invoke:${action.name}:${JSON.stringify(params)}`);
},
spec: { ...action },
};
channel.actions[action.name] = channel.action_specs[action.name].callback;
});
document.querySelectorAll('[jetzig-click]').forEach(element => {
const ref = element.getAttribute('jetzig-click');
const action = channel.action_specs[ref];
if (action) {
element.addEventListener('click', () => {
const args = [];
action.spec.params.forEach(param => {
const arg = element.dataset[param.name];
if (arg === undefined) {
throw new Error(`Expected 'data-${param.name}' attribute for '${action.name}' click handler.`);
} else {
args.push(element.dataset[param.name]);
}
});
action.callback(...args);
});
} else {
throw new Error(`Unknown click handler: '${ref}'`);
}
});
}; };
jetzig.channel = { jetzig.channel = {
websocket: null, websocket: null,
actions: {}, actions: {},
action_specs: {}, action_specs: {},
stateChangedCallbacks: [], stateChangedCallbacks: [],
messageCallbacks: [], messageCallbacks: [],
invokeCallbacks: {}, invokeCallbacks: {},
elementMap: {}, elementMap: {},
transformers: {}, transformers: {},
onStateChanged: function(callback) { this.stateChangedCallbacks.push(callback); }, onStateChanged: function(callback) { this.stateChangedCallbacks.push(callback); },
onMessage: function(callback) { this.messageCallbacks.push(callback); }, onMessage: function(callback) { this.messageCallbacks.push(callback); },
init: function(host, path) { init: function(host, path) {
this.websocket = new WebSocket(`ws://${host}${path}`); this.websocket = new WebSocket(`ws://${host}${path}`);
this.websocket.addEventListener("message", (event) => { this.websocket.addEventListener("message", (event) => {
const state_tag = "__jetzig_channel_state__:"; if (event.data.startsWith(state_tag)) {
const actions_tag = "__jetzig_actions__:"; handleState(event, this);
const event_tag = "__jetzig_event__:"; } else if (event.data.startsWith(event_tag)) {
handleEvent(event, this);
if (event.data.startsWith(state_tag)) { } else if (event.data.startsWith(actions_tag)) {
const state = JSON.parse(event.data.slice(state_tag.length)); handleAction(event, this);
Object.entries(this.elementMap).forEach(([ref, elements]) => { } else {
const value = reduceState(ref, state); const data = JSON.parse(event.data);
elements.forEach(element => element.innerHTML = transform(value, state, element)); this.messageCallbacks.forEach((callback) => {
}); callback(data);
this.stateChangedCallbacks.forEach((callback) => {
callback(state);
});
} else if (event.data.startsWith(event_tag)) {
const data = JSON.parse(event.data.slice(event_tag.length));
if (Object.hasOwn(this.invokeCallbacks, data.method)) {
this.invokeCallbacks[data.method].forEach(callback => {
callback(data);
});
}
} else if (event.data.startsWith(actions_tag)) {
const data = JSON.parse(event.data.slice(actions_tag.length));
data.actions.forEach(action => {
this.action_specs[action.name] = {
callback: (...params) => {
if (action.params.length != params.length) {
throw new Error(`Invalid params for action '${action.name}'. Expected ${action.params.length} params, found ${params.length}`);
}
[...action.params].forEach((param, index) => {
if (param.type !== typeof params[index]) {
const err = `Incorrect argument type for argument ${index} in '${action.name}'. Expected: ${param.type}, found ${typeof params[index]}`;
switch (param.type) {
case "string":
params[index] = `${params[index]}`;
break;
case "integer":
try { params[index] = parseInt(params[index]) } catch {
throw new Error(err);
};
break;
case "float":
try { params[index] = parseFloat(params[index]) } catch {
throw new Error(err);
};
case "boolean":
params[index] = ["true", "y", "1", "yes", "t"].includes(params[index]);
break;
default:
throw new Error(err);
}
}
});
this.websocket.send(`_invoke:${action.name}:${JSON.stringify(params)}`);
},
spec: { ...action },
};
this.actions[action.name] = this.action_specs[action.name].callback;
});
document.querySelectorAll('[jetzig-click]').forEach(element => {
const ref = element.getAttribute('jetzig-click');
const action = this.action_specs[ref];
if (action) {
element.addEventListener('click', () => {
const args = [];
action.spec.params.forEach(param => {
const arg = element.dataset[param.name];
if (arg === undefined) {
throw new Error(`Expected 'data-${param.name}' attribute for '${action.name}' click handler.`);
} else {
args.push(element.dataset[param.name]);
}
});
action.callback(...args);
});
} else {
throw new Error(`Unknown click handler: '${ref}'`);
}
});
} else {
const data = JSON.parse(event.data);
this.messageCallbacks.forEach((callback) => {
callback(data);
});
}
}); });
}
});
const reduceState = (ref, state) => { document.querySelectorAll('[jetzig-connect]').forEach(element => {
if (!ref.startsWith('$.')) throw new Error(`Unexpected ref format: ${ref}`); const ref = element.getAttribute('jetzig-connect');
const args = ref.split('.'); if (!this.elementMap[ref]) this.elementMap[ref] = [];
args.shift(); const id = `jetzig-${crypto.randomUUID()}`;
const isNumeric = (string) => [...string].every(char => '0123456789'.includes(char)); element.setAttribute('jetzig-id', id);
const isObject = (object) => object && typeof object === 'object'; this.elementMap[ref].push(element);
return args.reduce((acc, arg) => { const transformer = element.getAttribute('jetzig-transform');
if (isNumeric(arg)) { if (transformer) {
if (acc && Array.isArray(acc) && acc.length > arg) return acc[parseInt(arg)]; this.transformers[id] = new Function("value", "$", "element", `return ${transformer};`);
return null; }
} else { });
if (acc && isObject(acc)) return acc[arg];
return null;
}
}, state);
};
document.querySelectorAll('[jetzig-connect]').forEach(element => { const styled_elements = document.querySelectorAll('[jetzig-style]');
const ref = element.getAttribute('jetzig-connect'); this.onStateChanged(state => {
if (!this.elementMap[ref]) this.elementMap[ref] = []; styled_elements.forEach(element => {
const id = `jetzig-${crypto.randomUUID()}`; const func = new Function("$", `return ${element.getAttribute('jetzig-style')};`)
element.setAttribute('jetzig-id', id); const styles = func(state);
this.elementMap[ref].push(element); Object.entries(styles).forEach(([key, value]) => {
const transformer = element.getAttribute('jetzig-transform'); element.style.setProperty(key, value);
if (transformer) {
this.transformers[id] = new Function("value", "$", "element", `return ${transformer};`);
}
}); });
});
});
const styled_elements = document.querySelectorAll('[jetzig-style]'); // this.websocket.addEventListener("open", (event) => {
this.onStateChanged(state => { // // TODO
styled_elements.forEach(element => { // this.publish("websockets", {});
const func = new Function("$", `return ${element.getAttribute('jetzig-style')};`) // });
const styles = func(state); },
Object.entries(styles).forEach(([key, value]) => { receive: function(ref, callback) {
element.style.setProperty(key, value); if (Object.hasOwn(this.invokeCallbacks, ref)) {
}); this.invokeCallbacks[ref].push(callback);
}); } else {
}); this.invokeCallbacks[ref] = [callback];
}
// this.websocket.addEventListener("open", (event) => { },
// // TODO publish: function(data) {
// this.publish("websockets", {}); if (this.websocket) {
// }); const json = JSON.stringify(data);
}, this.websocket.send(json);
receive: function(ref, callback) { }
if (Object.hasOwn(this.invokeCallbacks, ref)) { },
this.invokeCallbacks[ref].push(callback);
} else {
this.invokeCallbacks[ref] = [callback];
}
},
publish: function(data) {
if (this.websocket) {
const json = JSON.stringify(data);
this.websocket.send(json);
}
},
}; };
})(); })();

View File

@ -9,6 +9,9 @@ pub const Context = struct {
route: jetzig.channels.Route, route: jetzig.channels.Route,
session_id: []const u8, session_id: []const u8,
channels: *jetzig.kv.Store.ChannelStore, channels: *jetzig.kv.Store.ChannelStore,
store: *jetzig.kv.Store.GeneralStore,
cache: *jetzig.kv.Store.CacheStore,
job_queue: *jetzig.kv.Store.JobQueueStore,
logger: jetzig.loggers.Logger, logger: jetzig.loggers.Logger,
}; };
@ -17,6 +20,9 @@ pub fn RoutedWebsocket(Routes: type) type {
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
connection: *httpz.websocket.Conn, connection: *httpz.websocket.Conn,
channels: *jetzig.kv.Store.ChannelStore, channels: *jetzig.kv.Store.ChannelStore,
store: *jetzig.kv.Store.GeneralStore,
cache: *jetzig.kv.Store.CacheStore,
job_queue: *jetzig.kv.Store.JobQueueStore,
route: jetzig.channels.Route, route: jetzig.channels.Route,
data: *jetzig.Data, data: *jetzig.Data,
session_id: []const u8, session_id: []const u8,
@ -35,6 +41,9 @@ pub fn RoutedWebsocket(Routes: type) type {
.route = context.route, .route = context.route,
.session_id = context.session_id, .session_id = context.session_id,
.channels = context.channels, .channels = context.channels,
.store = context.store,
.cache = context.cache,
.job_queue = context.job_queue,
.logger = context.logger, .logger = context.logger,
.data = data, .data = data,
}; };
@ -56,22 +65,12 @@ pub fn RoutedWebsocket(Routes: type) type {
const func = websocket.route.openConnectionFn orelse return; const func = websocket.route.openConnectionFn orelse return;
const channel = jetzig.channels.Channel{ const channel = try jetzig.channels.RoutedChannel(Routes).init(websocket.allocator, websocket);
.allocator = websocket.allocator,
.websocket = websocket,
.state = try websocket.getState(),
.data = websocket.data,
};
try func(channel); try func(channel);
} }
pub fn clientMessage(websocket: *Websocket, allocator: std.mem.Allocator, data: []const u8) !void { pub fn clientMessage(websocket: *Websocket, allocator: std.mem.Allocator, data: []const u8) !void {
const channel = jetzig.channels.RoutedChannel(Routes){ const channel = try jetzig.channels.RoutedChannel(Routes).init(allocator, websocket);
.allocator = allocator,
.websocket = websocket,
.state = try websocket.getState(),
.data = websocket.data,
};
if (websocket.invoke(channel, data)) |maybe_action| { if (websocket.invoke(channel, data)) |maybe_action| {
if (maybe_action) |action| { if (maybe_action) |action| {
@ -94,18 +93,20 @@ pub fn RoutedWebsocket(Routes: type) type {
websocket.logger.DEBUG("Routed Channel message for `{s}`", .{websocket.route.path}) catch {}; websocket.logger.DEBUG("Routed Channel message for `{s}`", .{websocket.route.path}) catch {};
} }
pub fn syncState(websocket: *Websocket, channel: jetzig.channels.RoutedChannel(Routes)) !void { pub fn syncState(websocket: *Websocket, data: *jetzig.Data, scope: []const u8) !void {
var stack_fallback = std.heap.stackFallback(4096, channel.allocator); const value = data.value orelse return;
var stack_fallback = std.heap.stackFallback(4096, websocket.allocator);
const allocator = stack_fallback.get(); const allocator = stack_fallback.get();
var write_buffer = channel.websocket.connection.writeBuffer(allocator, .text); var write_buffer = websocket.connection.writeBuffer(allocator, .text);
defer write_buffer.deinit(); defer write_buffer.deinit();
const writer = write_buffer.writer(); const writer = write_buffer.writer();
// TODO: Make this really fast. // TODO: Make this really fast.
try websocket.channels.put(websocket.session_id, channel.state); try websocket.channels.put(websocket.session_id, value);
try writer.print("__jetzig_channel_state__:{s}", .{try websocket.data.toJson()}); try writer.print("__jetzig_channel_state__:{s}:{s}", .{ scope, try data.toJson() });
try write_buffer.flush(); try write_buffer.flush();
websocket.logger.DEBUG("Synchronized Channel state for `{s}`", .{websocket.route.path}) catch {}; websocket.logger.DEBUG("Synchronized Channel state for `{s}`", .{websocket.route.path}) catch {};