Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ MESH_SECRET_KEY=your-secret-key-here
# Host Heartbeat Settings (Production defaults)
# Host sends heartbeat every 60 seconds
MESH_HOST_HEARTBEAT_INTERVAL_SECONDS=60
# Host group expires after 180 seconds of no heartbeat
MESH_HOST_HEARTBEAT_TTL_SECONDS=180
# Host group expires after 150 seconds of no heartbeat
MESH_HOST_HEARTBEAT_TTL_SECONDS=150

# Member Heartbeat Settings (Production defaults)
# Member sends heartbeat every 120 seconds (2 minutes)
Expand Down
11 changes: 9 additions & 2 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type Group {
hostId: ID! # 作成者ノード ID
createdAt: AWSDateTime!
expiresAt: AWSDateTime! # グループの有効期限
heartbeatIntervalSeconds: Int
}
```

Expand Down Expand Up @@ -110,6 +111,8 @@ query ListGroupsByDomain($domain: String!) {
name
hostId
createdAt
expiresAt
heartbeatIntervalSeconds
}
}
```
Expand Down Expand Up @@ -205,6 +208,8 @@ mutation CreateGroup($name: String!, $hostId: ID!, $domain: String!) {
name
hostId
createdAt
expiresAt
heartbeatIntervalSeconds
}
}
```
Expand All @@ -218,10 +223,12 @@ mutation CreateGroup($name: String!, $hostId: ID!, $domain: String!) {
```graphql
mutation JoinGroup($groupId: ID!, $nodeId: ID!, $domain: String!) {
joinGroup(groupId: $groupId, nodeId: $nodeId, domain: $domain) {
nodeId
id
name
groupId
domain
joinedAt
expiresAt
heartbeatIntervalSeconds
}
}
```
Expand Down
9 changes: 4 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,14 @@ sequenceDiagram
DynamoDB-->>Lambda: Success
Lambda-->>AppSync: 新規グループ
end
AppSync-->>Host: Group
AppSync-->>Host: Group (expiresAt, intervalSeconds)

Member->>AppSync: joinGroup(groupId, nodeId, domain)
AppSync->>DynamoDB: Pipeline: checkGroupExists
alt グループ存在
DynamoDB->>DynamoDB: PutItem: ノード登録
DynamoDB-->>AppSync: Node
AppSync-->>Member: Node
AppSync-->>Member: Node (expiresAt, intervalSeconds)
else グループなし
DynamoDB-->>AppSync: GroupNotFound error
AppSync-->>Member: Error
Expand Down Expand Up @@ -207,7 +207,7 @@ sequenceDiagram
Resolver->>DynamoDB: UpdateItem: expiresAt更新<br/>(TTL延長)
DynamoDB-->>Resolver: Success
Resolver-->>AppSync: HeartbeatPayload
AppSync-->>Host: expiresAt, intervalSeconds
AppSync-->>Host: expiresAt, heartbeatIntervalSeconds
else 非ホストまたはグループなし
Resolver-->>AppSync: Unauthorized / GroupNotFound
AppSync-->>Host: Error
Expand All @@ -220,7 +220,7 @@ sequenceDiagram
Resolver->>DynamoDB: UpdateItem: ノードTTL更新
DynamoDB-->>Resolver: Success
Resolver-->>AppSync: MemberHeartbeatPayload
AppSync-->>Member: expiresAt, intervalSeconds
AppSync-->>Member: expiresAt, heartbeatIntervalSeconds
end

Note over DynamoDB: TTL期限切れ(60-600秒後)
Expand Down Expand Up @@ -324,7 +324,6 @@ Mesh v2 は Single Table Design を採用し、1つのテーブルにすべて
"name": "Node 1",
"groupId": "abc123",
"domain": "192.168.1.1",
"heartbeatIntervalSeconds": 120,
"ttl": 1704067200
}
```
Expand Down
6 changes: 3 additions & 3 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ cp .env.example .env
| `MESH_HOST_HEARTBEAT_TTL_SECONDS` | `60` | `150` | ホストグループの有効期限(秒) |
| `MESH_MEMBER_HEARTBEAT_INTERVAL_SECONDS` | `15` | `120` | メンバーのハートビート送信間隔(秒) |
| `MESH_MEMBER_HEARTBEAT_TTL_SECONDS` | `60` | `600` | メンバーノードの有効期限(秒) |
| `MESH_MAX_CONNECTION_TIME_MINUTES` | `10` | `50` | グループの最大接続時間() |
| `MESH_MAX_CONNECTION_TIME_SECONDS` | `300` | `3000` | グループの最大接続時間() |

### 3.3 開発環境用の設定(stg)

Expand All @@ -91,7 +91,7 @@ MESH_HOST_HEARTBEAT_INTERVAL_SECONDS=15
MESH_HOST_HEARTBEAT_TTL_SECONDS=60
MESH_MEMBER_HEARTBEAT_INTERVAL_SECONDS=15
MESH_MEMBER_HEARTBEAT_TTL_SECONDS=60
MESH_MAX_CONNECTION_TIME_MINUTES=10
MESH_MAX_CONNECTION_TIME_SECONDS=300
```

### 3.4 本番環境用の設定(prod)
Expand All @@ -105,7 +105,7 @@ MESH_HOST_HEARTBEAT_INTERVAL_SECONDS=30
MESH_HOST_HEARTBEAT_TTL_SECONDS=150
MESH_MEMBER_HEARTBEAT_INTERVAL_SECONDS=120
MESH_MEMBER_HEARTBEAT_TTL_SECONDS=600
MESH_MAX_CONNECTION_TIME_MINUTES=50
MESH_MAX_CONNECTION_TIME_SECONDS=3000
```

**重要**:
Expand Down
8 changes: 4 additions & 4 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,11 @@ Mesh v2 は環境変数を使用して設定を管理し、開発環境と本番
| 変数 | 開発環境 | 本番環境 | 説明 |
|------|---------|---------|------|
| `MESH_SECRET_KEY` | `dev-secret-key-for-testing` | (GitHub Secrets で設定) | ドメイン検証用の秘密鍵 |
| `MESH_HOST_HEARTBEAT_INTERVAL_SECONDS` | `15` | `30` | ホストのハートビート間隔(秒) |
| `MESH_HOST_HEARTBEAT_TTL_SECONDS` | `60` | `150` | ホストグループの TTL(秒、間隔の5倍) |
| `MESH_HOST_HEARTBEAT_INTERVAL_SECONDS` | `15` | `60` | ホストのハートビート間隔(秒) |
| `MESH_HOST_HEARTBEAT_TTL_SECONDS` | `60` | `150` | ホストグループの TTL(秒) |
| `MESH_MEMBER_HEARTBEAT_INTERVAL_SECONDS` | `15` | `120` | メンバーのハートビート間隔(秒) |
| `MESH_MEMBER_HEARTBEAT_TTL_SECONDS` | `60` | `600` | メンバーノードの TTL(秒、間隔の5倍) |
| `MESH_MAX_CONNECTION_TIME_MINUTES` | `10` | `50` | グループの最大接続時間() |
| `MESH_MEMBER_HEARTBEAT_TTL_SECONDS` | `60` | `600` | メンバーノードの TTL(秒) |
| `MESH_MAX_CONNECTION_TIME_SECONDS` | `300` | `1500` | グループの最大接続時間() |

### 設定の根拠

Expand Down
53 changes: 38 additions & 15 deletions examples/javascript-client/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const state = {
sessionStartTime: null,
sessionTimerId: null,
heartbeatTimerId: null,
heartbeatIntervalSeconds: 60, // Default 60 seconds
messageSubscriptionId: null,
sensorData: {
temperature: 20,
Expand Down Expand Up @@ -257,6 +258,9 @@ async function handleCreateGroup() {

// Join the created group automatically
state.currentGroup = group;
if (group.heartbeatIntervalSeconds) {
state.heartbeatIntervalSeconds = group.heartbeatIntervalSeconds;
}

// Initialize sensor data for this node
// This immediately shares current sensor state with other group members
Expand Down Expand Up @@ -403,6 +407,10 @@ async function handleJoinGroup() {
expiresAt: result.expiresAt
};

if (result.heartbeatIntervalSeconds) {
state.heartbeatIntervalSeconds = result.heartbeatIntervalSeconds;
}

// Initialize sensor data for this node
// This immediately shares current sensor state with other group members
const initialData = [
Expand Down Expand Up @@ -431,6 +439,7 @@ async function handleJoinGroup() {

// Stop heartbeat if it was running (e.g. from a previously created group)
stopHeartbeat();
startHeartbeat();

showSuccess('groupSuccess', `Joined group: ${state.selectedGroup.name}`);
updateCurrentGroupUI();
Expand Down Expand Up @@ -861,15 +870,15 @@ function updateRateStatus() {
}

/**
* Start heartbeat timer (host only)
* Renews the group heartbeat every 15 seconds
* Start heartbeat timer
* Renews the group heartbeat periodically using server-provided interval
*/
function startHeartbeat() {
if (state.heartbeatTimerId) {
clearInterval(state.heartbeatTimerId);
}

console.log('Starting heartbeat timer...');
console.log(`Starting heartbeat timer (${state.heartbeatIntervalSeconds}s)...`);
document.getElementById('heartbeatStatus').style.display = 'block';

state.heartbeatTimerId = setInterval(async () => {
Expand All @@ -879,31 +888,45 @@ function startHeartbeat() {
}

const isHost = state.currentGroup.hostId === state.currentNodeId;
if (!isHost) {
stopHeartbeat();
return;
}

try {
const result = await state.client.renewHeartbeat(
state.currentGroup.id,
state.currentNodeId,
state.currentGroup.domain
);
console.log('Heartbeat renewed, expires at:', result.expiresAt);
let result;
if (isHost) {
result = await state.client.renewHeartbeat(
state.currentGroup.id,
state.currentNodeId,
state.currentGroup.domain
);
console.log('Host heartbeat renewed, expires at:', result.expiresAt);
} else {
result = await state.client.sendMemberHeartbeat(
state.currentGroup.id,
state.currentNodeId,
state.currentGroup.domain
);
console.log('Member heartbeat sent, expires at:', result.expiresAt);
}

document.getElementById('lastHeartbeatTime').textContent = new Date().toLocaleTimeString();

// Update session timer with new expiration if possible
if (result.expiresAt) {
state.currentGroup.expiresAt = result.expiresAt;
}

// Check if interval has changed
if (result.heartbeatIntervalSeconds && result.heartbeatIntervalSeconds !== state.heartbeatIntervalSeconds) {
console.log(`Heartbeat interval changed: ${state.heartbeatIntervalSeconds}s -> ${result.heartbeatIntervalSeconds}s`);
state.heartbeatIntervalSeconds = result.heartbeatIntervalSeconds;
startHeartbeat(); // Restart with new interval
}
} catch (error) {
console.error('Heartbeat renewal failed:', error);
console.error('Heartbeat failed:', error);
if (shouldDisconnectOnError(error)) {
handleGroupDissolved({ message: 'Session expired or group lost' });
}
}
}, 15000); // Every 15 seconds
}, state.heartbeatIntervalSeconds * 1000);
}

/**
Expand Down
26 changes: 26 additions & 0 deletions examples/javascript-client/mesh-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ class MeshClient {
hostId
createdAt
expiresAt
heartbeatIntervalSeconds
}
}
`;
Expand All @@ -118,6 +119,7 @@ class MeshClient {
mutation RenewHeartbeat($groupId: ID!, $hostId: ID!, $domain: String!) {
renewHeartbeat(groupId: $groupId, hostId: $hostId, domain: $domain) {
expiresAt
heartbeatIntervalSeconds
}
}
`;
Expand Down Expand Up @@ -243,6 +245,8 @@ class MeshClient {
name
groupId
domain
expiresAt
heartbeatIntervalSeconds
}
}
`;
Expand All @@ -256,6 +260,28 @@ class MeshClient {
return data.joinGroup;
}

/**
* Send member heartbeat
*/
async sendMemberHeartbeat(groupId, nodeId, domain) {
const query = `
mutation SendMemberHeartbeat($groupId: ID!, $nodeId: ID!, $domain: String!) {
sendMemberHeartbeat(groupId: $groupId, nodeId: $nodeId, domain: $domain) {
expiresAt
heartbeatIntervalSeconds
}
}
`;

const data = await this.execute(query, {
groupId,
nodeId,
domain: domain || this.domain
});

return data.sendMemberHeartbeat;
}

/**
* Leave a group
*/
Expand Down
1 change: 1 addition & 0 deletions graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type Group {
hostId: ID!
createdAt: AWSDateTime!
expiresAt: AWSDateTime!
heartbeatIntervalSeconds: Int
}

type Node {
Expand Down
9 changes: 7 additions & 2 deletions js/functions/createGroupIfNotExists.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ export function response(ctx) {
util.error(ctx.error.message, ctx.error.type);
}

// ハートビート間隔を環境変数から取得(ホスト用)
const heartbeatIntervalSeconds = +(ctx.env.MESH_HOST_HEARTBEAT_INTERVAL_SECONDS || '60');

// ctx.stashに既存グループがある場合はそれを返す
if (ctx.stash.existingGroup) {
return {
Expand All @@ -86,7 +89,8 @@ export function response(ctx) {
name: ctx.stash.existingGroup.name,
hostId: ctx.stash.existingGroup.hostId,
createdAt: ctx.stash.existingGroup.createdAt,
expiresAt: ctx.stash.existingGroup.expiresAt
expiresAt: ctx.stash.existingGroup.expiresAt,
heartbeatIntervalSeconds: heartbeatIntervalSeconds
};
}

Expand All @@ -98,6 +102,7 @@ export function response(ctx) {
name: ctx.result.name,
hostId: ctx.result.hostId,
createdAt: ctx.result.createdAt,
expiresAt: ctx.result.expiresAt
expiresAt: ctx.result.expiresAt,
heartbeatIntervalSeconds: heartbeatIntervalSeconds
};
}
Loading