前言
最近在学习websocket全双工通信,想要做一个联机小游戏,做游戏之前先做一个聊天室练练手。
跟着本篇博客,可以从0搭建一个属于你自己的聊天室。
准备阶段
什么人适合学习本篇文章?
答:前端开发者,有一定的HTML+CSS+JavaScript基础,会使用fetch进行网络请求
技术栈
- 前端三大件:HTML+CSS+JavaScript
- Redis数据库(本篇博客涉及到的是Redis基础中的基础,小白也可以轻松拿捏!)
- Node.js+Express框架+pug模板引擎(本篇博客后端部分使用Node.js的Express框架完成,不了解的同学也不需要害怕,代码简单易懂,开箱即用,直接复制即可使用)
工具准备
1.编译器
首先你需要有一个写代码的编译器,这里推荐两个(是我自己用的最多的,简单方便的):
- vscode:老牌编译器,功能强大,有丰富的插件资源,免费
官网直接下载: https://code.visualstudio.com/ - trea:AI编译器,新时代热门编译器,有国内和海外两个版本
国内:https://www.trae.cn/ide/download
优点:完全免费内置许多国内ai,也可自添模型
缺点:没有国外ai,模型数量比国外版少很多
海外:https://www.trae.ai/download
优点:部分免费,可充值为pro版,内置许多国外国内ai,可自添模型
缺点:国外热门ai如GPT,Claude模型等普通版要排队很久,pro版不用排队,但是每个月也有次数限制,一般够用
2.Node.js环境
两个方法:
1.使用nvm下载node版本
2.直接下载node
这里不管是新手还是老手都强烈建议使用nvm来下载node,nvm的优势在于可以同时管理多个node版本,并且在需要的时候随时切换不同版本
vnm
(下载安装中文网叙述的很清楚,这里不在赘述,注意一点,安装nvm前需要把电脑已有的node卸载):
下载:https://nvm.uihtm.com/doc/download-nvm.html
安装:https://nvm.uihtm.com/doc/install.html
node
(如果想要快速开始的话,可直接下载node版本,建议下载v20.x.x版本较稳定):
下载:https://nodejs.org/en/download
3.Redis数据库
redis是linux环境下开发的高并发性能好的键值类型数据库,要想在windows环境下使用,常用有三种方法:
- 使用windows虚拟机模拟linux环境,运行redis
- 使用Docker Desktop拉取镜像并在容器中运行
- 使用windows版redis,虽说比不上linux版的redis但是日常练习和教学完全够用了
这里我们主要讲第三种方法,想要使用windows版的redis需要访问GitHub上的开源库:https://github.com/redis-windows/redis-windows/releases,可能需要翻墙这个看运气
确保自己的电脑是windows,64位的,前四个都可下载,带service 的要比不带service的多一个自动添加服务的功能,你可以理解为有一个开机自启动Redis的功能,因为我们不需要这个功能,所以我选择下载的是第四个
下载好之后解压到一个文件夹里面,解压后的文件大概长这样
想要使用redis的服务,首先就是要运行起服务端
打开黑窗口运行:redis-server.exe redis.conf
出现下图的图案就算运行服务端成功了
两个注意点:
- 运行命令的文件路径需要是你解压后的文件夹路径,例如我这里就是:C:\software\redis,运行时需要换成你自己的路径否则会报命令不存在的错误
- 运行完之后这个黑窗口不能关,关了就取消服务了,就会访问不到redis了
解决方法:
要解决上面提到的找不到命令的问题,除了在正确的路径下运行命令外,还有一个很常用的方法,就是配置一下环境变量,这个很简单,这里就不讲了,如果有不懂的同学可以在评论区问我,到时候再解答
开始实践
如果你跟着步骤看到这里,你应该已经拥有了一个编译器(写代码的地方),Node.js环境(运行后端服务的地方),redis数据库(存储数据的地方)
有了这些之后可以正式开始写代码了!
1.搭建项目
找一个存放项目的文件夹,新建
chat-room
文件夹,用来存放项目
在trea里面打开
chat-room
文件夹(使用vscode打开也是一样的,不影响项目运行)
刚开始什么都没有,让我们先初始化一个package.json配置文件用来管理项目
使用npm工具来初始化package.json,这里我们使用
npm init -y
来快捷创建默认的配置文件
执行完命令后你的工作区应该长这个样子,并且内容如下
我们的聊天室前端部分pug语法渲染要和express框架结合,所以我们先下载express框架,引入pug语法,使用
npm install express pug nodemon
下载依赖(nodemon是我后来加的,下面图片没有,是可选的,推荐下载可以用来实现node服务运行的时候热重载)
按照下图,创建项目的基础结构
现在我们逐个文件填充代码,接下来的文件可直接复制使用,如果有疑问可直接复制给ai讲解代码,学会运用ai是当下我们程序员要培养的基本素养了
app.js:node服务启动文件
// 引入 express 模块
const express = require("express");
const path = require("path");
// 创建应用实例
const app = express();
// 1. 引入静态资源
app.get(express.static(path.join(__dirname, "public")));
// 2. 模板引擎
app.set("view engine", "pug");
// 3. 视图目录
app.set("views", path.join(__dirname, "views"));
// 拦截/地址路由,渲染登录页面
app.use("/", (req, res) => {
res.render("login", {
title: "请登录",
});
});
// 监听listen
app.listen(3000, () => {
console.log("服务器启动成功");
});
layout.pug:布局文件
doctype html
html
head
meta(charset='utf-8')
meta(name='viewport', content='width=device-width, initial-scale=1.0')
title= title
link(rel='stylesheet', href='/css/style.css')
body
block content
login.pug:登录页面
extend layout
block content
.container
.login-container
h1= title
form(method='post', action='/login' id='loginForm')
input(type='text',name='username',placeholder='请输入用户名',required,autocomplete='off')
button(type='submit') 进入聊天室
#error-message
script(src='/js/login.js')
style.css:样式文件(完整版的)
:root {
--primary-color: #4caf50;
--shadow-color: rgba(0, 0, 0, 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
width: 100%;
max-width: 400px;
padding: 20px;
}
.login-container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px var(--shadow-color);
}
h1 {
color: var(--primary-color);
text-align: center;
margin-bottom: 1.5rem;
font-size: 1.8rem;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
input {
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 4px;
font-size: 16px;
transition: border-color 0.3s;
}
input:focus {
outline: none;
border-color: var(--primary-color);
}
button {
background: var(--primary-color);
color: white;
border: none;
padding: 12px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: opacity 0.3s;
}
button:hover {
opacity: 0.9;
}
#error-message {
display: none;
padding: 10px;
margin-top: 15px;
border-radius: 4px;
background-color: #fef2f2;
border: 1px solid #fecaca;
color: #ef4444;
transition: opacity 0.3s ease;
}
#error-message.show {
display: block;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
写完这四个文件并且package.json中配置好启动命令,就可以启动项目了,我这里用的nodemon启动的项目,没有的同学可以把
start
后面的命令改为node app.js
,但是还是推荐先下载一下npm install nodemon
使用nodemon
启动项目
2.准备登录接口和静态聊天室
现在开始准备表单提交逻辑
public/js/login.js
// 获取表单元素,监听提交事件
document.getElementById("loginForm").addEventListener("submit", async (e) => {
// 阻止表单默认提交
e.preventDefault();
const formData = new FormData(e.target);
const username = formData.get("username");
try {
// 发送登录请求
const response = await fetch("/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "登录失败");
}
// 登录成功,重定向到聊天页面
if (data.success) {
window.location.href = "/chat";
}
} catch (error) {
showError(error.message);
}
});
function showError(message) {
// 显示错误提示
const errorDiv = document.getElementById("error-message");
errorDiv.textContent = message;
errorDiv.classList.add("show");
// 3秒后自动隐藏错误提示
setTimeout(() => {
errorDiv.classList.remove("show");
}, 3000);
}
下载两个依赖
npm install jsonwebtoken dotenv
用来提高登录功能的健壮性,根目录下新建.env
文件,更新根目录下的app.js
添加登录接口
// 引入 express 模块
const express = require("express");
const path = require("path");
const jwt = require("jsonwebtoken");
require("dotenv").config();
// 创建应用实例
const app = express();
// 1. 引入静态资源
app.use(express.static(path.join(__dirname, "public")));
// 2. 模板引擎
app.set("view engine", "pug");
// 3. 视图目录
app.set("views", path.join(__dirname, "views"));
// 4. 解析 JSON 请求体
app.use(express.json());
// 拦截/地址路由,渲染登录页面
app.get("/", (req, res) => {
res.render("login", {
title: "请登录",
});
});
// 处理登录接口
app.post("/login", async (req, res) => {
try {
const { username } = req.body;
if (!username) {
return res.status(400).send("用户名不能为空");
}
// 生成 token
const token = jwt.sign({ username }, process.env.JWT_SECRET, {
expiresIn: "2h",
});
res.cookie("token", token, { httpOnly: true });
res.json({
success: true,
});
} catch (e) {
console.error("登录失败:", e);
res.status(500).send("服务器错误");
}
});
// 监听listen
app.listen(3000, () => {
console.log("服务器启动成功");
});
现在可以登录了,但是还需要准备chat页面和对应的鉴权逻辑
新建views/chat.pug
extend layout
block content
.container
.chat-container
#messages
form#form.chat-form
input#input(type='text',placeholder='请输入消息...',autocomplete='off')
button(type='submit') 发送
script(src='/js/chat.js')
下载 npm install cookie-parser
依赖
更新app.js
// 引入 express 模块
const express = require("express");
const path = require("path");
const jwt = require("jsonwebtoken");
require("dotenv").config();
const cookieParser = require("cookie-parser");
// 创建应用实例
const app = express();
// 1. 引入静态资源
app.use(express.static(path.join(__dirname, "public")));
// 2. 模板引擎
app.set("view engine", "pug");
// 3. 视图目录
app.set("views", path.join(__dirname, "views"));
// 4. 解析 JSON 请求体
app.use(express.json());
// 5. 解析 Cookie
app.use(cookieParser());
// 拦截/地址路由,渲染登录页面
app.get("/", (req, res) => {
res.render("login", {
title: "请登录",
});
});
// 处理登录接口
app.post("/login", async (req, res) => {
try {
const { username } = req.body;
if (!username) {
return res.status(400).send("用户名不能为空");
}
// 生成 token
const token = jwt.sign({ username }, process.env.JWT_SECRET, {
expiresIn: "2h",
});
res.cookie("token", token, { httpOnly: true });
res.json({
success: true,
});
} catch (e) {
console.error("登录失败:", e);
res.status(500).send("服务器错误");
}
});
// 处理聊天室路由
app.use("/chat", mustAuth, (_, res) =>
res.render("chat", { title: "实时聊天室" })
);
/* ====== 中间件:JWT 校验 ====== */
function mustAuth(req, res, next) {
const token = req.cookies.token;
if (!token) return res.redirect("/");
try {
jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.redirect("/");
}
}
// 监听listen
app.listen(3000, () => {
console.log("服务器启动成功");
});
3.websocket实现实时通讯聊天室
现在登录功能和静态的聊天室页面已经准备好了,接下来准备设置websocket,实现实时通讯
设置websocket服务端:根目录下新建ws-server.js
,并引入npm install ws
依赖
ws.server.js
const WebSocket = require("ws");
const jwt = require("jsonwebtoken");
// 定义用户状态常量
const USER_STATUS = {
ONLINE: 1,
OFFLINE: 2,
};
// 启动 WebSocket 服务
module.exports = (server) => {
const wss = new WebSocket.Server({
server,
// 握手阶段拦截
verifyClient: (info, cb) => {
const cookies = info.req.headers.cookie || "";
console.log("cookies", cookies);
// 从 cookies 中提取 token
const token = cookies.match(/token=([^;]+)/)?.[1];
if (!token) return cb(false, 401, "Missing token");
try {
jwt.verify(token, process.env.JWT_SECRET);
cb(true); // 放行
} catch {
cb(false, 401, "Invalid token");
}
},
});
// 监听客户端连接
wss.on("connection", (ws, req) => {
console.log("客户端连接成功");
// 解析用户名
const cookies = req.headers.cookie || "";
const token = cookies.match(/token=([^;]+)/)?.[1];
ws.username = "匿名用户";
if (token) {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
ws.username = payload.username || ws.username;
} catch (e) {
// token无效,保持匿名
console.error("Token 验证失败:", e);
}
}
// 广播欢迎消息给所有客户端
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(
JSON.stringify({
type: "sys",
text: `欢迎 ${ws.username} 进入聊天室`,
number: wss.clients.size,
})
);
}
});
// 广播
ws.on("message", (data) => {
console.log("收到消息:", data);
const message = JSON.parse(data);
// 广播给所有客户端
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(
JSON.stringify({
type: "chat",
from: ws.username,
text: message.text,
time: new Date().toLocaleString(),
number: wss.clients.size,
})
);
}
});
});
// 监听断开连接
ws.on("close", async () => {
console.log(`用户 ${ws.username} 断开连接`);
try {
if (ws.username && ws.username !== "匿名用户") {
// 广播用户离开消息
wss.clients.forEach((client) => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(
JSON.stringify({
type: "sys",
text: `${ws.username} 离开了聊天室`,
number: wss.clients.size, // 减去即将断开的连接
})
);
}
});
}
} catch (error) {
console.error(`更新用户 ${ws.username} 状态失败:`, error);
}
});
});
};
准备websocket客户端,
public/js
目录下新建chat.js
public/js/chat.js
const socket = new WebSocket(`ws://${location.host}`);
const messages = document.getElementById("messages");
const form = document.getElementById("form");
const input = document.getElementById("input");
// 监听消息,并展示
socket.onmessage = (event) => {
const { type, from, text, time, number } = JSON.parse(event.data);
const div = document.createElement("div");
div.innerHTML =
type === "sys"
? `<em>${text},当前在线人数: ${number}</em>`
: `<strong>${from}</strong>: <small>${time}</small>:${text}`;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight; // 滚动到底部
};
// 发送消息
form.addEventListener("submit", (e) => {
e.preventDefault();
const message = input.value.trim();
if (!message) return;
try {
socket.send(JSON.stringify({ text: message }));
input.value = ""; // 清空输入框
} catch (err) {
console.error("发送消息失败:", err);
alert("发送失败,请检查网络连接");
}
});
更新app.js,建立websocket连接
// 引入 express 模块
const express = require("express");
const path = require("path");
const jwt = require("jsonwebtoken");
require("dotenv").config();
const cookieParser = require("cookie-parser");
// 创建应用实例
const app = express();
// 1. 引入静态资源
app.use(express.static(path.join(__dirname, "public")));
// 2. 模板引擎
app.set("view engine", "pug");
// 3. 视图目录
app.set("views", path.join(__dirname, "views"));
// 4. 解析 JSON 请求体
app.use(express.json());
// 5. 解析 Cookie
app.use(cookieParser());
// 拦截/地址路由,渲染登录页面
app.get("/", (req, res) => {
res.render("login", {
title: "请登录",
});
});
// 处理登录接口
app.post("/login", async (req, res) => {
try {
const { username } = req.body;
if (!username) {
return res.status(400).send("用户名不能为空");
}
// 生成 token
const token = jwt.sign({ username }, process.env.JWT_SECRET, {
expiresIn: "2h",
});
res.cookie("token", token, { httpOnly: true });
res.json({
success: true,
});
} catch (e) {
console.error("登录失败:", e);
res.status(500).send("服务器错误");
}
});
// 处理聊天页面路由
app.use("/chat", mustAuth, (_, res) =>
res.render("chat", { title: "实时聊天室" })
);
/* ====== 中间件:JWT 校验 ====== */
function mustAuth(req, res, next) {
const token = req.cookies.token;
if (!token) return res.redirect("/");
try {
jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.redirect("/");
}
}
app.use((err, req, res, next) => {
// 错误处理中间件
console.error(err);
});
// 监听listen
app.listen(3000, () => {
console.log("服务器启动成功");
});
// 启动 WebSocket 服务
require("./ws-server")(server);
到这里我们的实时聊天室已经实现了,项目根目录下运行
npm run start
就可以把我们的聊天室在本地localhost:3000
跑起来了
4.项目引入Redis,禁止重复登录
虽然我们的聊天室已经完成了,但是基础好的同学就会发现,我们的聊天室对于登录的账号是没有限制的,所以就可能出现同一个账户重复登录的情况
重复登录情况
现在我们要借用redis记录登录状态,进而防止重复登录
本地启动redis服务端
win+R
打开运行窗口输入cmd
打开黑窗口
在安装redis的目录下输入redis-server.exe redis.conf
启动redis服务,启动完服务后,这个黑窗口不能关,一关,redis就断开连接了
我们根目录下新建configs/redis.js,同时下载
npm install ioredis
用来在项目中配置连接redis
configs/redis.js
// 引入 ioredis 模块
const Redis = require("ioredis");
// 连接 Redis 数据库
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD || "123456",
});
// 监听 Redis 连接事件
redis.on("connect", () => {
console.log("Redis 连接成功");
});
// 监听 Redis 错误事件
redis.on("error", (err) => {
console.error("Redis 连接失败:", err);
});
// 导出 Redis 实例
module.exports = redis;
更新app.js
// 引入 express 模块
const express = require("express");
const path = require("path");
const jwt = require("jsonwebtoken");
require("dotenv").config();
const cookieParser = require("cookie-parser");
const redis = require("./configs/redis"); // 新引入redis实例
// 创建应用实例
const app = express();
// 1. 引入静态资源
app.use(express.static(path.join(__dirname, "public")));
// 2. 模板引擎
app.set("view engine", "pug");
// 3. 视图目录
app.set("views", path.join(__dirname, "views"));
// 4. 解析 JSON 请求体
app.use(express.json());
// 5. 解析 Cookie
app.use(cookieParser());
// 拦截/地址路由,渲染登录页面
app.get("/", (req, res) => {
res.render("login", {
title: "请登录",
});
});
// 处理登录接口
app.post("/login", async (req, res) => {
try {
const { username } = req.body;
if (!username) {
return res.status(400).send("用户名不能为空");
}
// 将用户信息存储到 Redis
const userKey = `user:${username}`;
if ((await redis.hget(userKey, "status")) === "1") {
return res.status(400).json({ error: "用户已登录,请勿重复登录" });
}
// 生成 token
const token = jwt.sign({ username }, process.env.JWT_SECRET, {
expiresIn: "2h",
});
// 如果用户已存在,更新状态
await redis.hset(userKey, "status", 1);
// 设置过期时间(2小时)
await redis.expire(userKey, 7200);
res.cookie("token", token, { httpOnly: true });
res.json({
success: true,
});
} catch (e) {
console.error("登录失败:", e);
res.status(500).send("服务器错误");
}
});
// 处理聊天页面路由
app.use("/chat", mustAuth, (_, res) =>
res.render("chat", { title: "实时聊天室" })
);
/* ====== 中间件:JWT 校验 ====== */
function mustAuth(req, res, next) {
const token = req.cookies.token;
if (!token) return res.redirect("/");
try {
jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.redirect("/");
}
}
app.use((err, req, res, next) => {
// 错误处理中间件
console.error(err);
});
// 监听listen
const server = app.listen(3000, () => {
console.log("服务器启动成功");
});
// 启动 WebSocket 服务
require("./ws-server")(server);
更新完app.js后,你再运行项目,就会发现,不能同时登录同一个账户了,但是还有一个问题,那就是即使我们退出一个账号,想再次登录的时候仍然登录不了!这是因为没有做用户离开聊天室时,对登录状态更改的逻辑!
更新根目录下的ws-server.js
处理用户聊天室后,取消登录状态
ws-server.js
const WebSocket = require("ws");
const jwt = require("jsonwebtoken");
const redis = require("./configs/redis");
// 定义用户状态常量
const USER_STATUS = {
ONLINE: 1,
OFFLINE: 2,
};
// 启动 WebSocket 服务
module.exports = (server) => {
const wss = new WebSocket.Server({
server,
// 握手阶段拦截
verifyClient: (info, cb) => {
const cookies = info.req.headers.cookie || "";
console.log("cookies", cookies);
// 从 cookies 中提取 token
const token = cookies.match(/token=([^;]+)/)?.[1];
if (!token) return cb(false, 401, "Missing token");
try {
jwt.verify(token, process.env.JWT_SECRET);
cb(true); // 放行
} catch {
cb(false, 401, "Invalid token");
}
},
});
// 监听客户端连接
wss.on("connection", (ws, req) => {
console.log("客户端连接成功");
// 解析用户名
const cookies = req.headers.cookie || "";
const token = cookies.match(/token=([^;]+)/)?.[1];
ws.username = "匿名用户";
if (token) {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
ws.username = payload.username || ws.username;
// 设置用户在线状态
const userKey = `user:${ws.username}`;
redis
.hset(userKey, "status", USER_STATUS.ONLINE)
.catch((err) => console.error("设置用户在线状态失败:", err));
} catch (e) {
// token无效,保持匿名
console.error("Token 验证失败:", e);
}
}
// 广播欢迎消息给所有客户端
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(
JSON.stringify({
type: "sys",
text: `欢迎 ${ws.username} 进入聊天室`,
number: wss.clients.size,
})
);
}
});
// 广播
ws.on("message", (data) => {
console.log("收到消息:", data);
const message = JSON.parse(data);
// 广播给所有客户端
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(
JSON.stringify({
type: "chat",
from: ws.username,
text: message.text,
time: new Date().toLocaleString(),
number: wss.clients.size,
})
);
}
});
});
// 监听断开连接
ws.on("close", async () => {
console.log(`用户 ${ws.username} 断开连接`);
try {
if (ws.username && ws.username !== "匿名用户") {
const userKey = `user:${ws.username}`;
// 更新用户状态为离线
await redis.hset(userKey, "status", USER_STATUS.OFFLINE);
console.log(`用户 ${ws.username} 状态已更新为离线`);
// 广播用户离开消息
wss.clients.forEach((client) => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(
JSON.stringify({
type: "sys",
text: `${ws.username} 离开了聊天室`,
number: wss.clients.size, // 减去即将断开的连接
})
);
}
});
}
} catch (error) {
console.error(`更新用户 ${ws.username} 状态失败:`, error);
}
});
});
};
未完待续
到此为止,我们已经拥有了一个有一定登录权限控制的实时聊天室了
但是我想我们的追求不应该止步于此,后续我会持续更新几个新功能
感兴趣的小伙伴可以适时再来查看这篇博客
- 2025.8.17:完成UI样式更新
- 2025.8.20:新增好友功能,实现单聊
有问题的同学欢迎在评论区讨论学习!!!
本篇博客涉及到的学习资源如下,想深入学习的同学可自行阅读:
WebSocket:https://developer.mozilla.org/zh-CN/docs/Web/API/WebSocket
Express:https://express.js.cn/en/guide/routing.html
Redis:https://www.runoob.com/redis/redis-tutorial.html