diff --git a/.env.example b/.env.example index 2067263..01f153f 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/docs/api-reference.md b/docs/api-reference.md index 64109fa..1348720 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -26,6 +26,7 @@ type Group { hostId: ID! # 作成者ノード ID createdAt: AWSDateTime! expiresAt: AWSDateTime! # グループの有効期限 + heartbeatIntervalSeconds: Int } ``` @@ -110,6 +111,8 @@ query ListGroupsByDomain($domain: String!) { name hostId createdAt + expiresAt + heartbeatIntervalSeconds } } ``` @@ -205,6 +208,8 @@ mutation CreateGroup($name: String!, $hostId: ID!, $domain: String!) { name hostId createdAt + expiresAt + heartbeatIntervalSeconds } } ``` @@ -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 } } ``` diff --git a/docs/architecture.md b/docs/architecture.md index da1412c..c66f96d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 @@ -207,7 +207,7 @@ sequenceDiagram Resolver->>DynamoDB: UpdateItem: expiresAt更新
(TTL延長) DynamoDB-->>Resolver: Success Resolver-->>AppSync: HeartbeatPayload - AppSync-->>Host: expiresAt, intervalSeconds + AppSync-->>Host: expiresAt, heartbeatIntervalSeconds else 非ホストまたはグループなし Resolver-->>AppSync: Unauthorized / GroupNotFound AppSync-->>Host: Error @@ -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秒後) @@ -324,7 +324,6 @@ Mesh v2 は Single Table Design を採用し、1つのテーブルにすべて "name": "Node 1", "groupId": "abc123", "domain": "192.168.1.1", - "heartbeatIntervalSeconds": 120, "ttl": 1704067200 } ``` diff --git a/docs/deployment.md b/docs/deployment.md index 70e719c..90336e9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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) @@ -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) @@ -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 ``` **重要**: diff --git a/docs/development.md b/docs/development.md index c464746..f5f8555 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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` | グループの最大接続時間(秒) | ### 設定の根拠 diff --git a/examples/javascript-client/app.js b/examples/javascript-client/app.js index d5e324a..4fe7537 100644 --- a/examples/javascript-client/app.js +++ b/examples/javascript-client/app.js @@ -15,6 +15,7 @@ const state = { sessionStartTime: null, sessionTimerId: null, heartbeatTimerId: null, + heartbeatIntervalSeconds: 60, // Default 60 seconds messageSubscriptionId: null, sensorData: { temperature: 20, @@ -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 @@ -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 = [ @@ -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(); @@ -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 () => { @@ -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); } /** diff --git a/examples/javascript-client/mesh-client.js b/examples/javascript-client/mesh-client.js index 20c5f70..f00513a 100644 --- a/examples/javascript-client/mesh-client.js +++ b/examples/javascript-client/mesh-client.js @@ -97,6 +97,7 @@ class MeshClient { hostId createdAt expiresAt + heartbeatIntervalSeconds } } `; @@ -118,6 +119,7 @@ class MeshClient { mutation RenewHeartbeat($groupId: ID!, $hostId: ID!, $domain: String!) { renewHeartbeat(groupId: $groupId, hostId: $hostId, domain: $domain) { expiresAt + heartbeatIntervalSeconds } } `; @@ -243,6 +245,8 @@ class MeshClient { name groupId domain + expiresAt + heartbeatIntervalSeconds } } `; @@ -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 */ diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 5a3b254..21cc0a2 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -20,6 +20,7 @@ type Group { hostId: ID! createdAt: AWSDateTime! expiresAt: AWSDateTime! + heartbeatIntervalSeconds: Int } type Node { diff --git a/js/functions/createGroupIfNotExists.js b/js/functions/createGroupIfNotExists.js index a9a10c9..20aa21a 100644 --- a/js/functions/createGroupIfNotExists.js +++ b/js/functions/createGroupIfNotExists.js @@ -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 { @@ -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 }; } @@ -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 }; }