Skip to content
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,17 @@ MESH_API_KEY=da2-your-api-key
# AWS Region (Production)
# Example: ap-northeast-1
MESH_AWS_REGION=ap-northeast-1

# Data update interval in milliseconds
# Default: 1000
MESH_DATA_UPDATE_INTERVAL_MS=1000

# Event batch interval in milliseconds
# Default: 1000
MESH_EVENT_BATCH_INTERVAL_MS=1000

# Debug logging (comma-separated categories, e.g., scratch-vm:*)
DEBUG=

# Development server port
PORT=8073
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ npm-*

# Load Test
/test/load-test/node_modules

# Environment
.env
1 change: 1 addition & 0 deletions src/extensions/scratch3_mesh_v2/gql-operations.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const CREATE_GROUP = gql`
hostId
createdAt
expiresAt
heartbeatIntervalSeconds
}
}
`;
Expand Down
62 changes: 51 additions & 11 deletions src/extensions/scratch3_mesh_v2/mesh-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,22 @@ const {
LIST_GROUP_STATUSES
} = require('./gql-operations');

const CONNECTION_TIMEOUT = 50 * 60 * 1000; // 50 minutes in milliseconds
/**
* Parses an environment variable as an integer with validation.
* @param {string} envVar - The environment variable value.
* @param {number} defaultValue - The default value if parsing fails or is out of range.
* @param {number} min - Minimum allowed value (inclusive).
* @param {number} max - Maximum allowed value (inclusive).
* @returns {number} The parsed integer or default value.
*/
const parseEnvInt = (envVar, defaultValue, min = 0, max = Infinity) => {
if (!envVar) return defaultValue;
const parsed = parseInt(envVar, 10);
if (isNaN(parsed) || parsed < min || parsed > max) return defaultValue;
return parsed;
};

// Mesh v2 configuration parameters

/**
* GraphQL error types that indicate the connection is no longer valid.
Expand Down Expand Up @@ -52,20 +67,34 @@ class MeshV2Service {
// Last data send promise to track completion of the most recent data transmission
this.lastDataSendPromise = Promise.resolve();

this.hostHeartbeatInterval = 60; // Default 1 min
this.memberHeartbeatInterval = 120; // Default 2 min

// Data from other nodes: { nodeId: { key: { value: string, timestamp: number } } }
this.remoteData = {};

// Rate limiters
this.dataRateLimiter = new RateLimiter(4, 250, {
// Data update interval (default: 1000ms)
const dataInterval = parseEnvInt(
process.env.MESH_DATA_UPDATE_INTERVAL_MS,
1000, // default
100, // min: 100ms
10000 // max: 10 seconds
);
this.dataRateLimiter = new RateLimiter(4, dataInterval, {
enableMerge: true,
mergeKeyField: 'key'
});

// Event queue for batch sending: { eventName, payload, firedAt } の配列
this.eventQueue = [];
this.eventBatchInterval = 250;
// Event batch interval (default: 1000ms)
this.eventBatchInterval = parseEnvInt(
process.env.MESH_EVENT_BATCH_INTERVAL_MS,
1000, // default
100, // min: 100ms
10000 // max: 10 seconds
);
this.eventBatchTimer = null;

// Event queue limits
Expand Down Expand Up @@ -198,6 +227,9 @@ class MeshV2Service {
this.domain = group.domain; // Update domain from server
this.expiresAt = group.expiresAt;
this.isHost = true;
if (group.heartbeatIntervalSeconds) {
this.hostHeartbeatInterval = group.heartbeatIntervalSeconds;
}

this.costTracking.connectionStartTime = Date.now();
this.startSubscriptions();
Expand Down Expand Up @@ -656,8 +688,7 @@ class MeshV2Service {
if (!this.groupId) return;

log.info(`Mesh V2: Starting heartbeat timer (Role: ${this.isHost ? 'Host' : 'Member'})`);
// Use 15s for host, memberHeartbeatInterval for member (default 120s)
const interval = this.isHost ? 15 * 1000 : this.memberHeartbeatInterval * 1000;
const interval = (this.isHost ? this.hostHeartbeatInterval : this.memberHeartbeatInterval) * 1000;

this.heartbeatTimer = setInterval(() => {
if (this.isHost) {
Expand Down Expand Up @@ -692,6 +723,13 @@ class MeshV2Service {
});
this.expiresAt = result.data.renewHeartbeat.expiresAt;
log.info(`Mesh V2: Heartbeat renewed. Expires at: ${this.expiresAt}`);
if (result.data.renewHeartbeat.heartbeatIntervalSeconds) {
const newInterval = result.data.renewHeartbeat.heartbeatIntervalSeconds;
if (newInterval !== this.hostHeartbeatInterval) {
this.hostHeartbeatInterval = newInterval;
this.startHeartbeat(); // Restart with new interval
}
}
this.startConnectionTimer();
return result.data.renewHeartbeat;
} catch (error) {
Expand Down Expand Up @@ -741,13 +779,15 @@ class MeshV2Service {

startConnectionTimer () {
this.stopConnectionTimer();
let timeout = CONNECTION_TIMEOUT;
if (this.expiresAt) {
const serverTimeout = new Date(this.expiresAt).getTime() - Date.now();
if (serverTimeout > 0) {
timeout = serverTimeout;
}
if (!this.expiresAt) return;

const timeout = new Date(this.expiresAt).getTime() - Date.now();
if (timeout <= 0) {
log.warn('Mesh V2: Group is already expired');
this.leaveGroup();
return;
}

const timeoutMinutes = Math.round(timeout / 60000);
this.connectionTimer = setTimeout(() => {
log.warn(`Mesh V2: Connection timeout (${timeoutMinutes} minutes)`);
Expand Down
5 changes: 5 additions & 0 deletions test/unit/extension_mesh_v2.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
const test = require('tap').test;
const minilog = require('minilog');
// Suppress debug and info logs during tests
minilog.suggest.deny('vm', 'debug');
minilog.suggest.deny('vm', 'info');

const URLSearchParams = require('url').URLSearchParams;
const MeshV2Blocks = require('../../src/extensions/scratch3_mesh_v2/index.js');
const Variable = require('../../src/engine/variable');
Expand Down
5 changes: 5 additions & 0 deletions test/unit/extension_mesh_v2_integration.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
const test = require('tap').test;
const minilog = require('minilog');
// Suppress debug and info logs during tests
minilog.suggest.deny('vm', 'debug');
minilog.suggest.deny('vm', 'info');

const URLSearchParams = require('url').URLSearchParams;
const MeshBlocks = require('../../src/extensions/scratch3_mesh/index.js');
const MeshV2Blocks = require('../../src/extensions/scratch3_mesh_v2/index.js');
Expand Down
5 changes: 5 additions & 0 deletions test/unit/extension_mesh_v2_issue66.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
const test = require('tap').test;
const minilog = require('minilog');
// Suppress debug and info logs during tests
minilog.suggest.deny('vm', 'debug');
minilog.suggest.deny('vm', 'info');

const URLSearchParams = require('url').URLSearchParams;
const MeshV2Blocks = require('../../src/extensions/scratch3_mesh_v2/index.js');
const Variable = require('../../src/engine/variable');
Expand Down
16 changes: 12 additions & 4 deletions test/unit/extension_mesh_v2_service.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
/* eslint-disable require-atomic-updates */
const test = require('tap').test;
const minilog = require('minilog');
// Suppress debug logs during tests
minilog.suggest.deny('vm', 'debug');

const MeshV2Service = require('../../src/extensions/scratch3_mesh_v2/mesh-service');
const log = require('../../src/util/log');

Expand Down Expand Up @@ -35,18 +39,22 @@ test('MeshV2Service Cost Tracking', t => {
id: 'g1',
name: 'G1',
domain: 'd1',
expiresAt: '2026-01-01T00:00:00Z'
expiresAt: '2099-01-01T00:00:00Z',
heartbeatIntervalSeconds: 60
},
joinGroup: {
id: 'n1',
domain: 'd1',
expiresAt: '2026-01-01T00:00:00Z'
expiresAt: '2099-01-01T00:00:00Z',
heartbeatIntervalSeconds: 120
},
renewHeartbeat: {
expiresAt: '2026-01-01T00:00:00Z'
expiresAt: '2099-01-01T00:00:00Z',
heartbeatIntervalSeconds: 60
},
sendMemberHeartbeat: {
expiresAt: '2026-01-01T00:00:00Z'
expiresAt: '2099-01-01T00:00:00Z',
heartbeatIntervalSeconds: 120
}
}
}),
Expand Down
5 changes: 5 additions & 0 deletions test/unit/mesh_service_v2.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
const test = require('tap').test;
const minilog = require('minilog');
// Suppress debug and info logs during tests
minilog.suggest.deny('vm', 'debug');
minilog.suggest.deny('vm', 'info');

const MeshV2Service = require('../../src/extensions/scratch3_mesh_v2/mesh-service');
const {FIRE_EVENTS} = require('../../src/extensions/scratch3_mesh_v2/gql-operations');
const BlockUtility = require('../../src/engine/block-utility');
Expand Down
8 changes: 7 additions & 1 deletion test/unit/mesh_service_v2_global_vars.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
const test = require('tap').test;
const minilog = require('minilog');
// Suppress debug and info logs during tests
minilog.suggest.deny('vm', 'debug');
minilog.suggest.deny('vm', 'info');

const MeshV2Service = require('../../src/extensions/scratch3_mesh_v2/mesh-service');
const Variable = require('../../src/engine/variable');

Expand Down Expand Up @@ -104,7 +109,8 @@ test('MeshV2Service Global Variables', t => {
id: 'group1',
name: 'groupName',
domain: 'domain1',
expiresAt: '2025-12-30T12:00:00Z'
expiresAt: '2099-01-01T00:00:00Z',
heartbeatIntervalSeconds: 60
}
}
}),
Expand Down
5 changes: 5 additions & 0 deletions test/unit/mesh_service_v2_integration.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
const test = require('tap').test;
const minilog = require('minilog');
// Suppress debug and info logs during tests
minilog.suggest.deny('vm', 'debug');
minilog.suggest.deny('vm', 'info');

const MeshV2Service = require('../../src/extensions/scratch3_mesh_v2/mesh-service');
const BlockUtility = require('../../src/engine/block-utility');

Expand Down
5 changes: 5 additions & 0 deletions test/unit/mesh_service_v2_order.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
const test = require('tap').test;
const minilog = require('minilog');
// Suppress debug and info logs during tests
minilog.suggest.deny('vm', 'debug');
minilog.suggest.deny('vm', 'info');

const MeshV2Service = require('../../src/extensions/scratch3_mesh_v2/mesh-service');
const {REPORT_DATA, FIRE_EVENTS} = require('../../src/extensions/scratch3_mesh_v2/gql-operations');

Expand Down
5 changes: 5 additions & 0 deletions test/unit/mesh_service_v2_subscription.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
const test = require('tap').test;
const minilog = require('minilog');
// Suppress debug and info logs during tests
minilog.suggest.deny('vm', 'debug');
minilog.suggest.deny('vm', 'info');

const MeshV2Service = require('../../src/extensions/scratch3_mesh_v2/mesh-service');
const {ON_MESSAGE_IN_GROUP} = require('../../src/extensions/scratch3_mesh_v2/gql-operations');

Expand Down
5 changes: 5 additions & 0 deletions test/unit/mesh_service_v2_timestamp.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
const test = require('tap').test;
const minilog = require('minilog');
// Suppress debug and info logs during tests
minilog.suggest.deny('vm', 'debug');
minilog.suggest.deny('vm', 'info');

const MeshV2Service = require('../../src/extensions/scratch3_mesh_v2/mesh-service');

const createMockBlocks = () => ({
Expand Down
4 changes: 3 additions & 1 deletion webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ const webBuilder = new ScratchWebpackConfigBuilder(common)
.addPlugin(new webpack.DefinePlugin({
'process.env.MESH_GRAPHQL_ENDPOINT': JSON.stringify(process.env.MESH_GRAPHQL_ENDPOINT),
'process.env.MESH_API_KEY': JSON.stringify(process.env.MESH_API_KEY),
'process.env.MESH_AWS_REGION': JSON.stringify(process.env.MESH_AWS_REGION)
'process.env.MESH_AWS_REGION': JSON.stringify(process.env.MESH_AWS_REGION),
'process.env.MESH_DATA_UPDATE_INTERVAL_MS': JSON.stringify(process.env.MESH_DATA_UPDATE_INTERVAL_MS),
'process.env.MESH_EVENT_BATCH_INTERVAL_MS': JSON.stringify(process.env.MESH_EVENT_BATCH_INTERVAL_MS)
}));

const playgroundBuilder = webBuilder.clone()
Expand Down
Loading