mirror of
https://github.com/jetzig-framework/jetzig.git
synced 2025-05-14 22:16:08 +00:00
WIP
This commit is contained in:
parent
b795c6184e
commit
d3113d12fe
@ -2,33 +2,36 @@
|
|||||||
|
|
||||||
<div id="party-container"></div>
|
<div id="party-container"></div>
|
||||||
|
|
||||||
<div id="results-wrapper">
|
<jetzig-scope name="game">
|
||||||
<span class="trophy">🏆</span>
|
<div id="results-wrapper">
|
||||||
<div id="results">
|
<span class="trophy">🏆</span>
|
||||||
<div>Player</div>
|
<div id="results">
|
||||||
<div id="player-wins" jetzig-connect="$.results.player"></div>
|
<div>Player</div>
|
||||||
<div>CPU</div>
|
<div id="player-wins" jetzig-scope="game" jetzig-connect="$.results.player"></div>
|
||||||
<div id="cpu-wins" jetzig-connect="$.results.cpu"></div>
|
<div>CPU</div>
|
||||||
<div>Tie</div>
|
<div id="cpu-wins" jetzig-scope="game" jetzig-connect="$.results.cpu"></div>
|
||||||
<div id="ties" jetzig-connect="$.results.tie"></div>
|
<div>Tie</div>
|
||||||
</div>
|
<div id="ties" jetzig-scope="game" jetzig-connect="$.results.tie"></div>
|
||||||
<span class="trophy">🏆</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="board" id="board">
|
|
||||||
@for (0..9) |index| {
|
|
||||||
<div
|
|
||||||
class="cell"
|
|
||||||
jetzig-connect="$.cells.{{index}}"
|
|
||||||
jetzig-transform="{ player: '✈️', cpu: '🦎', tie: '🤝' }[value] || ''"
|
|
||||||
jetzig-click="move"
|
|
||||||
id="tic-tac-toe-cell-{{index}}"
|
|
||||||
data-cell="{{index}}"
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
<span class="trophy">🏆</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="board" id="board">
|
||||||
|
@for (0..9) |index| {
|
||||||
|
<div
|
||||||
|
class="cell"
|
||||||
|
jetzig-connect="$.cells.{{index}}"
|
||||||
|
jetzig-transform="{ player: '✈️', cpu: '🦎', tie: '🤝' }[value] || ''"
|
||||||
|
jetzig-scope="game"
|
||||||
|
jetzig-click="move"
|
||||||
|
id="tic-tac-toe-cell-{{index}}"
|
||||||
|
data-cell="{{index}}"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</jetzig-scope>
|
||||||
|
|
||||||
|
|
||||||
<div id="reset-wrapper">
|
<div id="reset-wrapper">
|
||||||
<button jetzig-click="reset" id="reset-button">Reset Game</button>
|
<button jetzig-click="reset" id="reset-button">Reset Game</button>
|
||||||
@ -38,23 +41,24 @@
|
|||||||
<span>🏆</span>
|
<span>🏆</span>
|
||||||
<span
|
<span
|
||||||
jetzig-connect="$.victor"
|
jetzig-connect="$.victor"
|
||||||
|
jetzig-scope="game"
|
||||||
jetzig-transform="{ player: '✈️', cpu: '🦎', tie: '🤝' }[value] || ''"
|
jetzig-transform="{ player: '✈️', cpu: '🦎', tie: '🤝' }[value] || ''"
|
||||||
></span>
|
></span>
|
||||||
<span>🏆</span>
|
<span>🏆</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@// jetzig.channel.onStateChanged(state => {
|
@// Jetzig.channel.onStateChanged((scope, state) => {
|
||||||
@// });
|
@// });
|
||||||
@//
|
@//
|
||||||
@// jetzig.channel.onMessage(data => {
|
@// Jetzig.channel.onMessage(data => {
|
||||||
@// });
|
@// });
|
||||||
@//
|
@//
|
||||||
@// jetzig.channel.receive("victor", data => {
|
@// Jetzig.channel.receive("victor", data => {
|
||||||
@// triggerPartyAnimation();
|
@// triggerPartyAnimation();
|
||||||
@// });
|
@// });
|
||||||
@//
|
@//
|
||||||
@// jetzig.channel.receive("game_over", data => {
|
@// Jetzig.channel.receive("game_over", data => {
|
||||||
@// const element = document.querySelector("#board");
|
@// const element = document.querySelector("#board");
|
||||||
@// element.classList.remove('flash-animation');
|
@// element.classList.remove('flash-animation');
|
||||||
@// void element.offsetWidth;
|
@// void element.offsetWidth;
|
||||||
|
@ -23,8 +23,8 @@ pub fn RoutedChannel(Routes: type) type {
|
|||||||
env: Env,
|
env: Env,
|
||||||
|
|
||||||
const Connection = struct {
|
const Connection = struct {
|
||||||
object: *jetzig.data.Value,
|
state: *jetzig.data.Value,
|
||||||
data: *jetzig.Data,
|
key: []const u8,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn init(allocator: std.mem.Allocator, websocket: *jetzig.websockets.Websocket) !Channel {
|
pub fn init(allocator: std.mem.Allocator, websocket: *jetzig.websockets.Websocket) !Channel {
|
||||||
@ -86,7 +86,9 @@ pub fn RoutedChannel(Routes: type) type {
|
|||||||
|
|
||||||
pub fn connect(channel: Channel, comptime scope: []const u8) !*jetzig.data.Value {
|
pub fn connect(channel: Channel, comptime scope: []const u8) !*jetzig.data.Value {
|
||||||
if (channel._connections.get(scope)) |cached| {
|
if (channel._connections.get(scope)) |cached| {
|
||||||
return cached.object;
|
// Ensure an identical value is returned for each invocation of `connect` for a
|
||||||
|
// given scope.
|
||||||
|
return cached.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channel.websocket.session_id.len != 32) return error.JetzigInvalidSessionIdLength;
|
if (channel.websocket.session_id.len != 32) return error.JetzigInvalidSessionIdLength;
|
||||||
@ -105,19 +107,14 @@ pub fn RoutedChannel(Routes: type) type {
|
|||||||
break :blk id;
|
break :blk id;
|
||||||
};
|
};
|
||||||
|
|
||||||
var buf: [32 + ":".len + 32]u8 = undefined;
|
const connection_key = try std.fmt.allocPrint(channel.allocator, "{s}:{s}", .{ channel.websocket.session_id, connection_id });
|
||||||
const connection_key = try std.fmt.bufPrint(&buf, "{s}:{s}", .{ channel.websocket.session_id, connection_id });
|
const state = try channel.websocket.channels.get(channel.data, connection_key) orelse blk: {
|
||||||
return try channel.websocket.channels.get(channel.data, connection_key) orelse blk: {
|
const state = try channel.data.object();
|
||||||
const data = try channel.allocator.create(jetzig.Data);
|
try channel.websocket.channels.put(connection_key, state);
|
||||||
data.* = jetzig.Data.init(channel.allocator);
|
break :blk state;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
try channel._connections.put(scope, .{ .key = connection_key, .state = state });
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getT(
|
pub fn getT(
|
||||||
@ -145,12 +142,13 @@ pub fn RoutedChannel(Routes: type) type {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn sync(channel: Channel) !void {
|
pub fn sync(channel: Channel) !void {
|
||||||
try channel.websocket.syncState(channel.data, "__root__");
|
try channel.websocket.syncState(channel.state, "__root__", channel.websocket.session_id);
|
||||||
|
|
||||||
var it = channel._connections.iterator();
|
var it = channel._connections.iterator();
|
||||||
while (it.next()) |entry| {
|
while (it.next()) |entry| {
|
||||||
const data = entry.value_ptr.*.data;
|
const connection = entry.value_ptr.*;
|
||||||
const scope = entry.key_ptr.*;
|
const scope = entry.key_ptr.*;
|
||||||
try channel.websocket.syncState(data, scope);
|
try channel.websocket.syncState(connection.state, scope, connection.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -155,8 +155,7 @@ pub fn Store(comptime options: KVOptions) type {
|
|||||||
|
|
||||||
fn parseValue(data: *jetzig.data.Data, maybe_json: ?[]const u8) !?*jetzig.data.Value {
|
fn parseValue(data: *jetzig.data.Data, maybe_json: ?[]const u8) !?*jetzig.data.Value {
|
||||||
if (maybe_json) |json| {
|
if (maybe_json) |json| {
|
||||||
try data.fromJson(json);
|
return try data.parseJsonSlice(json);
|
||||||
return data.value.?;
|
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,7 @@ pub const Blocks = struct {
|
|||||||
\\<script>
|
\\<script>
|
||||||
\\ (() => {{
|
\\ (() => {{
|
||||||
\\ window.addEventListener('DOMContentLoaded', () => {{
|
\\ window.addEventListener('DOMContentLoaded', () => {{
|
||||||
\\ jetzig.channel.init("{s}", "{s}");
|
\\ Jetzig.channel.init("{s}", "{s}");
|
||||||
\\ }});
|
\\ }});
|
||||||
\\ }})();
|
\\ }})();
|
||||||
\\</script>
|
\\</script>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
window.jetzig = window.jetzig ? window.jetzig : {}
|
window.Jetzig = window.Jetzig ? window.Jetzig : {}
|
||||||
jetzig = window.jetzig;
|
const Jetzig = window.Jetzig;
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
const state_tag = "__jetzig_channel_state__:";
|
const state_tag = "__jetzig_channel_state__:";
|
||||||
@ -8,7 +8,7 @@ jetzig = window.jetzig;
|
|||||||
|
|
||||||
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 {
|
} else {
|
||||||
@ -38,12 +38,14 @@ jetzig = window.jetzig;
|
|||||||
const detagged = event.data.slice(state_tag.length);
|
const detagged = event.data.slice(state_tag.length);
|
||||||
const scope = detagged.split(':', 1)[0];
|
const scope = detagged.split(':', 1)[0];
|
||||||
const state = JSON.parse(detagged.slice(scope.length + 1));
|
const state = JSON.parse(detagged.slice(scope.length + 1));
|
||||||
Object.entries(channel.elementMap).forEach(([ref, elements]) => {
|
console.log(scope, state);
|
||||||
|
Object.entries(channel.scopedElements(scope)).forEach(([ref, elements]) => {
|
||||||
const value = reduceState(ref, state);
|
const value = reduceState(ref, state);
|
||||||
|
console.log(ref, state);
|
||||||
elements.forEach(element => element.innerHTML = transform(value, state, element));
|
elements.forEach(element => element.innerHTML = transform(value, state, element));
|
||||||
});
|
});
|
||||||
channel.stateChangedCallbacks.forEach((callback) => {
|
channel.stateChangedCallbacks.forEach((callback) => {
|
||||||
callback(state);
|
callback(scope, state);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -61,40 +63,40 @@ jetzig = window.jetzig;
|
|||||||
data.actions.forEach(action => {
|
data.actions.forEach(action => {
|
||||||
channel.action_specs[action.name] = {
|
channel.action_specs[action.name] = {
|
||||||
callback: (...params) => {
|
callback: (...params) => {
|
||||||
if (action.params.length != params.length) {
|
if (action.params.length != params.length) {
|
||||||
throw new Error(`Invalid params for action '${action.name}'. Expected ${action.params.length} params, found ${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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
[...action.params].forEach((param, index) => {
|
||||||
|
if (param.type !== typeof params[index]) {
|
||||||
channel.websocket.send(`_invoke:${action.name}:${JSON.stringify(params)}`);
|
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", true].includes(params[index]);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
channel.websocket.send(`_invoke:${action.name}:${JSON.stringify(params)}`);
|
||||||
},
|
},
|
||||||
spec: { ...action },
|
spec: { ...action },
|
||||||
};
|
};
|
||||||
channel.actions[action.name] = channel.action_specs[action.name].callback;
|
channel.actions[action.name] = channel.action_specs[action.name].callback;
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('[jetzig-click]').forEach(element => {
|
document.querySelectorAll('[jetzig-click]').forEach(element => {
|
||||||
const ref = element.getAttribute('jetzig-click');
|
const ref = element.getAttribute('jetzig-click');
|
||||||
const action = channel.action_specs[ref];
|
const action = channel.action_specs[ref];
|
||||||
@ -117,7 +119,58 @@ jetzig = window.jetzig;
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
jetzig.channel = {
|
const initElementConnections = (channel) => {
|
||||||
|
document.querySelectorAll('[jetzig-connect]').forEach(element => {
|
||||||
|
const ref = element.getAttribute('jetzig-connect');
|
||||||
|
const id = `jetzig-${crypto.randomUUID()}`;
|
||||||
|
element.setAttribute('jetzig-id', id);
|
||||||
|
const scope = element.getAttribute('jetzig-scope') || '__root__';
|
||||||
|
if (!channel.elementMap[scope]) channel.elementMap[scope] = {};
|
||||||
|
if (!channel.elementMap[scope][ref]) channel.elementMap[scope][ref] = [];
|
||||||
|
channel.elementMap[scope][ref].push(element);
|
||||||
|
const transformer = element.getAttribute('jetzig-transform');
|
||||||
|
if (transformer) {
|
||||||
|
channel.transformers[id] = new Function("value", "$", "element", `return ${transformer};`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initStyledElements = (channel) => {
|
||||||
|
const styled_elements = document.querySelectorAll('[jetzig-style]');
|
||||||
|
channel.onStateChanged(state => {
|
||||||
|
styled_elements.forEach(element => {
|
||||||
|
const func = new Function("$", `return ${element.getAttribute('jetzig-style')};`)
|
||||||
|
const styles = func(state);
|
||||||
|
Object.entries(styles).forEach(([key, value]) => {
|
||||||
|
element.style.setProperty(key, value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initWebsocket = (channel, host, path) => {
|
||||||
|
channel.websocket = new WebSocket(`ws://${host}${path}`);
|
||||||
|
channel.websocket.addEventListener("message", (event) => {
|
||||||
|
if (event.data.startsWith(state_tag)) {
|
||||||
|
handleState(event, channel);
|
||||||
|
} else if (event.data.startsWith(event_tag)) {
|
||||||
|
handleEvent(event, channel);
|
||||||
|
} else if (event.data.startsWith(actions_tag)) {
|
||||||
|
handleAction(event, channel);
|
||||||
|
} else {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
channel.messageCallbacks.forEach((callback) => {
|
||||||
|
callback(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
channel.websocket.addEventListener("open", (event) => {
|
||||||
|
// TODO
|
||||||
|
channel.publish("websockets", {});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Jetzig.channel = {
|
||||||
websocket: null,
|
websocket: null,
|
||||||
actions: {},
|
actions: {},
|
||||||
action_specs: {},
|
action_specs: {},
|
||||||
@ -128,50 +181,11 @@ jetzig = window.jetzig;
|
|||||||
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); },
|
||||||
|
scopedElements: function(scope) { return this.elementMap[scope] || {}; },
|
||||||
init: function(host, path) {
|
init: function(host, path) {
|
||||||
this.websocket = new WebSocket(`ws://${host}${path}`);
|
initWebsocket(this, host, path);
|
||||||
this.websocket.addEventListener("message", (event) => {
|
initElementConnections(this);
|
||||||
if (event.data.startsWith(state_tag)) {
|
initStyledElements(this);
|
||||||
handleState(event, this);
|
|
||||||
} else if (event.data.startsWith(event_tag)) {
|
|
||||||
handleEvent(event, this);
|
|
||||||
} else if (event.data.startsWith(actions_tag)) {
|
|
||||||
handleAction(event, this);
|
|
||||||
} else {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
this.messageCallbacks.forEach((callback) => {
|
|
||||||
callback(data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('[jetzig-connect]').forEach(element => {
|
|
||||||
const ref = element.getAttribute('jetzig-connect');
|
|
||||||
if (!this.elementMap[ref]) this.elementMap[ref] = [];
|
|
||||||
const id = `jetzig-${crypto.randomUUID()}`;
|
|
||||||
element.setAttribute('jetzig-id', id);
|
|
||||||
this.elementMap[ref].push(element);
|
|
||||||
const transformer = element.getAttribute('jetzig-transform');
|
|
||||||
if (transformer) {
|
|
||||||
this.transformers[id] = new Function("value", "$", "element", `return ${transformer};`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const styled_elements = document.querySelectorAll('[jetzig-style]');
|
|
||||||
this.onStateChanged(state => {
|
|
||||||
styled_elements.forEach(element => {
|
|
||||||
const func = new Function("$", `return ${element.getAttribute('jetzig-style')};`)
|
|
||||||
const styles = func(state);
|
|
||||||
Object.entries(styles).forEach(([key, value]) => {
|
|
||||||
element.style.setProperty(key, value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// this.websocket.addEventListener("open", (event) => {
|
|
||||||
// // TODO
|
|
||||||
// this.publish("websockets", {});
|
|
||||||
// });
|
|
||||||
},
|
},
|
||||||
receive: function(ref, callback) {
|
receive: function(ref, callback) {
|
||||||
if (Object.hasOwn(this.invokeCallbacks, ref)) {
|
if (Object.hasOwn(this.invokeCallbacks, ref)) {
|
||||||
|
@ -93,9 +93,7 @@ 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, data: *jetzig.Data, scope: []const u8) !void {
|
pub fn syncState(websocket: *Websocket, value: *jetzig.data.Value, scope: []const u8, state_key: []const u8) !void {
|
||||||
const value = data.value orelse return;
|
|
||||||
|
|
||||||
var stack_fallback = std.heap.stackFallback(4096, websocket.allocator);
|
var stack_fallback = std.heap.stackFallback(4096, websocket.allocator);
|
||||||
const allocator = stack_fallback.get();
|
const allocator = stack_fallback.get();
|
||||||
|
|
||||||
@ -105,8 +103,8 @@ pub fn RoutedWebsocket(Routes: type) type {
|
|||||||
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, value);
|
try websocket.channels.put(state_key, value);
|
||||||
try writer.print("__jetzig_channel_state__:{s}:{s}", .{ scope, try data.toJson() });
|
try writer.print("__jetzig_channel_state__:{s}:{s}", .{ scope, try value.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 {};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user