@sinch/functions-runtime 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -185,6 +185,122 @@ function validateBasicAuth(authHeader, expectedKey, expectedSecret) {
185
185
  return keyMatch && secretMatch;
186
186
  }
187
187
 
188
+ // ../runtime-shared/dist/security/index.js
189
+ function shouldValidateWebhook(mode, isDevelopment) {
190
+ const normalizedMode = (mode || "deploy").toLowerCase();
191
+ switch (normalizedMode) {
192
+ case "never":
193
+ return false;
194
+ case "always":
195
+ return true;
196
+ case "deploy":
197
+ default:
198
+ return !isDevelopment;
199
+ }
200
+ }
201
+ var VALID_MODES = ["never", "deploy", "always"];
202
+ function getProtectionMode(config) {
203
+ const envValue = process.env.WEBHOOK_PROTECTION ?? process.env.PROTECT_VOICE_CALLBACKS;
204
+ if (envValue) {
205
+ const normalized = envValue.toLowerCase();
206
+ if (VALID_MODES.includes(normalized)) {
207
+ return normalized;
208
+ }
209
+ console.warn(`[SECURITY] Unknown WEBHOOK_PROTECTION value "${envValue}", defaulting to "deploy"`);
210
+ return "deploy";
211
+ }
212
+ const configValue = config?.WebhookProtection ?? config?.webhookProtection ?? config?.ProtectVoiceCallbacks ?? config?.protectVoiceCallbacks;
213
+ if (typeof configValue === "string") {
214
+ const normalized = configValue.toLowerCase();
215
+ if (VALID_MODES.includes(normalized)) {
216
+ return normalized;
217
+ }
218
+ console.warn(`[SECURITY] Unknown webhook protection value "${configValue}", defaulting to "deploy"`);
219
+ return "deploy";
220
+ }
221
+ return "deploy";
222
+ }
223
+
224
+ // ../runtime-shared/dist/sinch/index.js
225
+ import { SinchClient, validateAuthenticationHeader, ConversationCallbackWebhooks } from "@sinch/sdk-core";
226
+ function createSinchClients() {
227
+ const clients = {};
228
+ const hasCredentials = process.env.PROJECT_ID && process.env.PROJECT_ID_API_KEY && process.env.PROJECT_ID_API_SECRET;
229
+ if (process.env.CONVERSATION_WEBHOOK_SECRET) {
230
+ const callbackProcessor = new ConversationCallbackWebhooks(process.env.CONVERSATION_WEBHOOK_SECRET);
231
+ clients.validateConversationWebhook = (headers, body) => {
232
+ try {
233
+ const result = callbackProcessor.validateAuthenticationHeader(headers, body);
234
+ console.log("[SINCH] Conversation webhook validation:", result ? "VALID" : "INVALID");
235
+ return result;
236
+ } catch (error) {
237
+ console.error("[SINCH] Conversation validation error:", error instanceof Error ? error.message : error);
238
+ return false;
239
+ }
240
+ };
241
+ }
242
+ if (!hasCredentials) {
243
+ return clients;
244
+ }
245
+ try {
246
+ const sinchClient = new SinchClient({
247
+ projectId: process.env.PROJECT_ID,
248
+ keyId: process.env.PROJECT_ID_API_KEY,
249
+ keySecret: process.env.PROJECT_ID_API_SECRET
250
+ });
251
+ if (process.env.CONVERSATION_APP_ID) {
252
+ clients.conversation = sinchClient.conversation;
253
+ console.log("[SINCH] Conversation API initialized");
254
+ }
255
+ if (process.env.VOICE_APPLICATION_KEY && process.env.VOICE_APPLICATION_SECRET) {
256
+ const voiceClient = new SinchClient({
257
+ projectId: process.env.PROJECT_ID,
258
+ keyId: process.env.PROJECT_ID_API_KEY,
259
+ keySecret: process.env.PROJECT_ID_API_SECRET,
260
+ applicationKey: process.env.VOICE_APPLICATION_KEY,
261
+ applicationSecret: process.env.VOICE_APPLICATION_SECRET
262
+ });
263
+ clients.voice = voiceClient.voice;
264
+ console.log("[SINCH] Voice API initialized with application credentials");
265
+ }
266
+ if (process.env.SMS_SERVICE_PLAN_ID) {
267
+ clients.sms = sinchClient.sms;
268
+ console.log("[SINCH] SMS API initialized");
269
+ }
270
+ if (process.env.ENABLE_NUMBERS_API === "true") {
271
+ clients.numbers = sinchClient.numbers;
272
+ console.log("[SINCH] Numbers API initialized");
273
+ }
274
+ } catch (error) {
275
+ console.error("[SINCH] Failed to initialize Sinch clients:", error.message);
276
+ return {};
277
+ }
278
+ if (process.env.VOICE_APPLICATION_KEY && process.env.VOICE_APPLICATION_SECRET) {
279
+ clients.validateWebhookSignature = (requestData) => {
280
+ console.log("[SINCH] Validating Voice webhook signature");
281
+ try {
282
+ const result = validateAuthenticationHeader(process.env.VOICE_APPLICATION_KEY, process.env.VOICE_APPLICATION_SECRET, requestData.headers, requestData.body, requestData.path, requestData.method);
283
+ console.log("[SINCH] Validation result:", result ? "VALID" : "INVALID");
284
+ return result;
285
+ } catch (error) {
286
+ console.error("[SINCH] Validation error:", error.message);
287
+ return false;
288
+ }
289
+ };
290
+ }
291
+ return clients;
292
+ }
293
+ var cachedClients = null;
294
+ function getSinchClients() {
295
+ if (!cachedClients) {
296
+ cachedClients = createSinchClients();
297
+ }
298
+ return cachedClients;
299
+ }
300
+ function resetSinchClients() {
301
+ cachedClients = null;
302
+ }
303
+
188
304
  // ../runtime-shared/dist/host/app.js
189
305
  import { createRequire as createRequire2 } from "module";
190
306
  import { pathToFileURL } from "url";
@@ -385,7 +501,7 @@ var noOpStorage = {
385
501
  };
386
502
  function buildBaseContext(req, config = {}) {
387
503
  return {
388
- requestId: req.headers["x-request-id"] || generateRequestId(),
504
+ requestId: req?.headers?.["x-request-id"] || generateRequestId(),
389
505
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
390
506
  env: process.env,
391
507
  config: {
@@ -406,7 +522,13 @@ function buildBaseContext(req, config = {}) {
406
522
  async function handleVoiceCallback(functionName, userFunction, context, callbackData, logger) {
407
523
  const handler = userFunction[functionName];
408
524
  if (!handler || typeof handler !== "function") {
409
- throw new Error(`Function '${functionName}' not found in function.js`);
525
+ if (functionName === "ice") {
526
+ throw new Error(`Voice callback 'ice' not found \u2014 export an ice() function in function.ts`);
527
+ }
528
+ if (logger) {
529
+ logger(`${functionName.toUpperCase()} callback not implemented \u2014 returning 200`);
530
+ }
531
+ return { statusCode: 200, body: {}, headers: {} };
410
532
  }
411
533
  let result;
412
534
  switch (functionName) {
@@ -449,7 +571,9 @@ async function handleCustomEndpoint(functionName, userFunction, context, request
449
571
  handler = userFunction["home"];
450
572
  }
451
573
  if (!handler || typeof handler !== "function") {
452
- throw new Error(`Function '${functionName}' not found in function.js`);
574
+ const available = Object.keys(userFunction).filter((k) => typeof userFunction[k] === "function");
575
+ const pathHint = functionName.endsWith("Webhook") ? `/webhook/${functionName.replace("Webhook", "")}` : `/${functionName}`;
576
+ throw new Error(`No export '${functionName}' found for path ${pathHint}. Available exports: [${available.join(", ")}]. Custom endpoints require a named export matching the last path segment.`);
453
577
  }
454
578
  const result = await handler(context, request);
455
579
  if (logger) {
@@ -481,6 +605,42 @@ function createApp(options = {}) {
481
605
  app.use(express.urlencoded({ extended: true, limit: "10mb" }));
482
606
  return app;
483
607
  }
608
+ function createSinchRuntime() {
609
+ const result = {
610
+ startupHandlers: [],
611
+ wsHandlers: /* @__PURE__ */ new Map()
612
+ };
613
+ const runtime = {
614
+ onStartup(handler) {
615
+ result.startupHandlers.push(handler);
616
+ },
617
+ onWebSocket(path7, handler) {
618
+ const normalizedPath = path7.startsWith("/") ? path7 : `/${path7}`;
619
+ result.wsHandlers.set(normalizedPath, handler);
620
+ }
621
+ };
622
+ return { runtime, result };
623
+ }
624
+ function installWebSocketHandlers(server, wsHandlers, logger = console.log) {
625
+ if (wsHandlers.size === 0)
626
+ return;
627
+ const { WebSocketServer } = requireCjs2("ws");
628
+ const wss = new WebSocketServer({ noServer: true });
629
+ server.on("upgrade", (req, socket, head) => {
630
+ const pathname = new URL(req.url || "/", `http://${req.headers.host}`).pathname;
631
+ const handler = wsHandlers.get(pathname);
632
+ if (handler) {
633
+ logger(`[WS] Upgrade request for ${pathname}`);
634
+ wss.handleUpgrade(req, socket, head, (ws) => {
635
+ handler(ws, req);
636
+ });
637
+ } else {
638
+ logger(`[WS] No handler for ${pathname} \u2014 rejecting upgrade`);
639
+ socket.destroy();
640
+ }
641
+ });
642
+ logger(`[WS] WebSocket endpoints registered: ${[...wsHandlers.keys()].join(", ")}`);
643
+ }
484
644
  function setupRequestHandler(app, options = {}) {
485
645
  const { loadUserFunction = async () => {
486
646
  const functionPath = findFunctionPath();
@@ -526,6 +686,33 @@ function setupRequestHandler(app, options = {}) {
526
686
  }
527
687
  }
528
688
  }
689
+ const protectionMode = getProtectionMode();
690
+ const isDev = process.env.NODE_ENV !== "production" && process.env.ASPNETCORE_ENVIRONMENT !== "Production";
691
+ if (shouldValidateWebhook(protectionMode, isDev)) {
692
+ const sinchClients = getSinchClients();
693
+ if (isVoiceCallback(functionName) && sinchClients.validateWebhookSignature) {
694
+ const rawBody = req.rawBody ?? JSON.stringify(req.body);
695
+ const isValid = sinchClients.validateWebhookSignature({
696
+ method: req.method,
697
+ path: req.path,
698
+ headers: req.headers,
699
+ body: rawBody
700
+ });
701
+ if (!isValid) {
702
+ logger("[SECURITY] Voice webhook signature validation failed");
703
+ res.status(401).json({ error: "Invalid webhook signature" });
704
+ return;
705
+ }
706
+ }
707
+ if (functionName === "conversationWebhook" && sinchClients.validateConversationWebhook) {
708
+ const isValid = sinchClients.validateConversationWebhook(req.headers, req.body);
709
+ if (!isValid) {
710
+ logger("[SECURITY] Conversation webhook signature validation failed");
711
+ res.status(401).json({ error: "Invalid webhook signature" });
712
+ return;
713
+ }
714
+ }
715
+ }
529
716
  onRequestStart({ functionName, req });
530
717
  const context = buildContext(req);
531
718
  const userFunction = await Promise.resolve(loadUserFunction());
@@ -601,70 +788,6 @@ function setupRequestHandler(app, options = {}) {
601
788
  });
602
789
  }
603
790
 
604
- // ../runtime-shared/dist/sinch/index.js
605
- import { SinchClient, validateAuthenticationHeader } from "@sinch/sdk-core";
606
- function createSinchClients() {
607
- const clients = {};
608
- const hasCredentials = process.env.PROJECT_ID && process.env.PROJECT_ID_API_KEY && process.env.PROJECT_ID_API_SECRET;
609
- if (!hasCredentials) {
610
- return clients;
611
- }
612
- try {
613
- const sinchClient = new SinchClient({
614
- projectId: process.env.PROJECT_ID,
615
- keyId: process.env.PROJECT_ID_API_KEY,
616
- keySecret: process.env.PROJECT_ID_API_SECRET
617
- });
618
- if (process.env.CONVERSATION_APP_ID) {
619
- clients.conversation = sinchClient.conversation;
620
- console.log("[SINCH] Conversation API initialized");
621
- }
622
- if (process.env.VOICE_APPLICATION_KEY && process.env.VOICE_APPLICATION_SECRET) {
623
- const voiceClient = new SinchClient({
624
- projectId: process.env.PROJECT_ID,
625
- keyId: process.env.PROJECT_ID_API_KEY,
626
- keySecret: process.env.PROJECT_ID_API_SECRET,
627
- applicationKey: process.env.VOICE_APPLICATION_KEY,
628
- applicationSecret: process.env.VOICE_APPLICATION_SECRET
629
- });
630
- clients.voice = voiceClient.voice;
631
- console.log("[SINCH] Voice API initialized with application credentials");
632
- }
633
- if (process.env.SMS_SERVICE_PLAN_ID) {
634
- clients.sms = sinchClient.sms;
635
- console.log("[SINCH] SMS API initialized");
636
- }
637
- if (process.env.ENABLE_NUMBERS_API === "true") {
638
- clients.numbers = sinchClient.numbers;
639
- console.log("[SINCH] Numbers API initialized");
640
- }
641
- } catch (error) {
642
- console.error("[SINCH] Failed to initialize Sinch clients:", error.message);
643
- return {};
644
- }
645
- if (process.env.VOICE_APPLICATION_KEY && process.env.VOICE_APPLICATION_SECRET) {
646
- clients.validateWebhookSignature = (requestData) => {
647
- console.log("[SINCH] Validating Voice webhook signature");
648
- try {
649
- const result = validateAuthenticationHeader(process.env.VOICE_APPLICATION_KEY, process.env.VOICE_APPLICATION_SECRET, requestData.headers, requestData.body, requestData.path, requestData.method);
650
- console.log("[SINCH] Validation result:", result ? "VALID" : "INVALID");
651
- return result;
652
- } catch (error) {
653
- console.error("[SINCH] Validation error:", error.message);
654
- return false;
655
- }
656
- };
657
- }
658
- return clients;
659
- }
660
- var cachedClients = null;
661
- function getSinchClients() {
662
- if (!cachedClients) {
663
- cachedClients = createSinchClients();
664
- }
665
- return cachedClients;
666
- }
667
-
668
791
  // ../runtime-shared/dist/ai/elevenlabs/state.js
669
792
  var ElevenLabsStateManager = class {
670
793
  state = {
@@ -1268,6 +1391,7 @@ import axios from "axios";
1268
1391
 
1269
1392
  // src/tunnel/webhook-config.ts
1270
1393
  import { SinchClient as SinchClient2 } from "@sinch/sdk-core";
1394
+ import { randomBytes } from "crypto";
1271
1395
  var SINCH_FN_URL_PATTERN = /\.fn(-\w+)?\.sinch\.com/;
1272
1396
  function isOurWebhook(target) {
1273
1397
  return !!target && SINCH_FN_URL_PATTERN.test(target);
@@ -1275,7 +1399,7 @@ function isOurWebhook(target) {
1275
1399
  function isTunnelUrl(target) {
1276
1400
  return !!target && target.includes("tunnel.fn");
1277
1401
  }
1278
- async function configureConversationWebhooks(tunnelUrl, config) {
1402
+ async function configureConversationWebhooks(webhookUrl, config) {
1279
1403
  try {
1280
1404
  const conversationAppId = process.env.CONVERSATION_APP_ID;
1281
1405
  const projectId = process.env.PROJECT_ID;
@@ -1285,7 +1409,6 @@ async function configureConversationWebhooks(tunnelUrl, config) {
1285
1409
  console.log("\u{1F4A1} Conversation API not fully configured - skipping webhook setup");
1286
1410
  return;
1287
1411
  }
1288
- const webhookUrl = `${tunnelUrl}/webhook/conversation`;
1289
1412
  console.log(`\u{1F4AC} Conversation webhook URL: ${webhookUrl}`);
1290
1413
  const sinchClient = new SinchClient2({
1291
1414
  projectId,
@@ -1305,17 +1428,22 @@ async function configureConversationWebhooks(tunnelUrl, config) {
1305
1428
  }
1306
1429
  config.conversationWebhookId = deployedWebhook.id;
1307
1430
  config.originalTarget = deployedWebhook.target;
1431
+ const hmacSecret = randomBytes(32).toString("hex");
1432
+ process.env.CONVERSATION_WEBHOOK_SECRET = hmacSecret;
1433
+ resetSinchClients();
1308
1434
  await sinchClient.conversation.webhooks.update({
1309
1435
  webhook_id: deployedWebhook.id,
1310
1436
  webhookUpdateRequestBody: {
1311
- target: webhookUrl
1437
+ target: webhookUrl,
1438
+ secret: hmacSecret
1312
1439
  },
1313
- update_mask: ["target"]
1440
+ update_mask: ["target", "secret"]
1314
1441
  });
1315
1442
  console.log(`\u2705 Updated Conversation webhook to tunnel: ${webhookUrl}`);
1443
+ console.log("\u{1F512} HMAC secret configured for webhook signature validation");
1316
1444
  console.log("\u{1F4AC} Send a message to your Conversation app to test!");
1317
1445
  } catch (error) {
1318
- console.log("\u26A0\uFE0F Could not configure Conversation webhooks:", error.message);
1446
+ console.log("\u26A0\uFE0F Could not configure Conversation webhooks:", error instanceof Error ? error.message : error);
1319
1447
  }
1320
1448
  }
1321
1449
  async function cleanupConversationWebhook(config) {
@@ -1352,8 +1480,10 @@ async function cleanupConversationWebhook(config) {
1352
1480
  console.log(`\u{1F504} Restored webhook target to: ${restoreTarget}`);
1353
1481
  config.conversationWebhookId = void 0;
1354
1482
  config.originalTarget = void 0;
1483
+ delete process.env.CONVERSATION_WEBHOOK_SECRET;
1484
+ resetSinchClients();
1355
1485
  } catch (error) {
1356
- console.log("\u26A0\uFE0F Could not restore Conversation webhook:", error.message);
1486
+ console.log("\u26A0\uFE0F Could not restore Conversation webhook:", error instanceof Error ? error.message : error);
1357
1487
  }
1358
1488
  }
1359
1489
  async function configureElevenLabs() {
@@ -1368,7 +1498,7 @@ async function configureElevenLabs() {
1368
1498
  console.log("\u{1F916} ElevenLabs auto-configuration enabled");
1369
1499
  console.log(` Agent ID: ${agentId}`);
1370
1500
  } catch (error) {
1371
- console.log("\u26A0\uFE0F Could not configure ElevenLabs:", error.message);
1501
+ console.log("\u26A0\uFE0F Could not configure ElevenLabs:", error instanceof Error ? error.message : error);
1372
1502
  }
1373
1503
  }
1374
1504
 
@@ -1405,14 +1535,14 @@ var TunnelClient = class {
1405
1535
  timestampPart = ENCODING[t % 32] + timestampPart;
1406
1536
  t = Math.floor(t / 32);
1407
1537
  }
1408
- const randomBytes = new Uint8Array(10);
1409
- crypto.getRandomValues(randomBytes);
1538
+ const randomBytes2 = new Uint8Array(10);
1539
+ crypto.getRandomValues(randomBytes2);
1410
1540
  let randomPart = "";
1411
1541
  for (let i = 0; i < 10; i++) {
1412
- const byte = randomBytes[i];
1542
+ const byte = randomBytes2[i];
1413
1543
  randomPart += ENCODING[byte >> 3];
1414
1544
  if (randomPart.length < 16) {
1415
- randomPart += ENCODING[(byte & 7) << 2 | (i + 1 < 10 ? randomBytes[i + 1] >> 6 : 0)];
1545
+ randomPart += ENCODING[(byte & 7) << 2 | (i + 1 < 10 ? randomBytes2[i + 1] >> 6 : 0)];
1416
1546
  }
1417
1547
  }
1418
1548
  randomPart = randomPart.substring(0, 16);
@@ -1485,6 +1615,15 @@ var TunnelClient = class {
1485
1615
  break;
1486
1616
  }
1487
1617
  }
1618
+ /**
1619
+ * Build a full tunnel URL with optional sub-path and tunnel query param.
1620
+ * e.g. buildTunnelUrl('/webhook/conversation') →
1621
+ * https://tunnel.fn.sinch.com/ingress/webhook/conversation?tunnel=01KKT...
1622
+ */
1623
+ buildTunnelUrl(path7) {
1624
+ const base = this.tunnelUrl.replace(/\/$/, "");
1625
+ return `${base}${path7 || ""}?tunnel=${this.tunnelId}`;
1626
+ }
1488
1627
  handleWelcomeMessage(message) {
1489
1628
  this.tunnelId = message.tunnelId || null;
1490
1629
  this.tunnelUrl = message.publicUrl || null;
@@ -1495,7 +1634,11 @@ var TunnelClient = class {
1495
1634
  }
1496
1635
  }
1497
1636
  async handleRequest(message) {
1637
+ const verbose = process.env.VERBOSE === "true";
1498
1638
  console.log(`Forwarding ${message.method} request to ${message.path}`);
1639
+ if (verbose && message.body) {
1640
+ console.log(` \u2190 Request body: ${message.body.substring(0, 2e3)}`);
1641
+ }
1499
1642
  try {
1500
1643
  const localUrl = `http://localhost:${this.localPort}${message.path}${message.query || ""}`;
1501
1644
  const axiosConfig = {
@@ -1528,9 +1671,15 @@ var TunnelClient = class {
1528
1671
  headers,
1529
1672
  body
1530
1673
  };
1674
+ if (verbose) {
1675
+ console.log(` \u2192 Response ${response.status}: ${body.substring(0, 2e3)}`);
1676
+ }
1531
1677
  this.ws?.send(JSON.stringify(responseMessage));
1532
1678
  } catch (error) {
1533
- console.error("Error forwarding request:", error.message);
1679
+ console.error(`Error forwarding request: ${error.message} (${error.response?.status || "no response"})`);
1680
+ if (verbose && error.response?.data) {
1681
+ console.error(` \u2192 Error body: ${typeof error.response.data === "string" ? error.response.data : JSON.stringify(error.response.data)}`.substring(0, 2e3));
1682
+ }
1534
1683
  const errorResponse = {
1535
1684
  type: "response",
1536
1685
  id: message.id,
@@ -1574,7 +1723,7 @@ var TunnelClient = class {
1574
1723
  await this.configureVoiceWebhooks();
1575
1724
  }
1576
1725
  if (autoConfigConversation && process.env.CONVERSATION_APP_ID) {
1577
- await configureConversationWebhooks(this.tunnelUrl, this.webhookConfig);
1726
+ await configureConversationWebhooks(this.buildTunnelUrl("/webhook/conversation"), this.webhookConfig);
1578
1727
  }
1579
1728
  if (process.env.ELEVENLABS_AUTO_CONFIGURE === "true") {
1580
1729
  await configureElevenLabs();
@@ -1601,7 +1750,7 @@ var TunnelClient = class {
1601
1750
  updateUrl,
1602
1751
  {
1603
1752
  url: {
1604
- primary: this.tunnelUrl,
1753
+ primary: this.buildTunnelUrl(),
1605
1754
  fallback: null
1606
1755
  }
1607
1756
  },
@@ -1663,7 +1812,8 @@ var TunnelClient = class {
1663
1812
  this.isConnected = false;
1664
1813
  }
1665
1814
  getTunnelUrl() {
1666
- return this.tunnelUrl;
1815
+ if (!this.tunnelUrl || !this.tunnelId) return null;
1816
+ return this.buildTunnelUrl();
1667
1817
  }
1668
1818
  getIsConnected() {
1669
1819
  return this.isConnected;
@@ -1786,19 +1936,19 @@ function displayApplicationCredentials() {
1786
1936
  console.log(' Tip: Add VOICE_APPLICATION_KEY to .env and run "sinch auth login"');
1787
1937
  }
1788
1938
  }
1789
- async function displayDetectedFunctions() {
1939
+ async function displayDetectedFunctions(port) {
1790
1940
  try {
1791
1941
  const functionPath = findFunctionPath3();
1792
1942
  if (!fs5.existsSync(functionPath)) return;
1793
1943
  const functionUrl = pathToFileURL2(functionPath).href;
1794
1944
  const module = await import(functionUrl);
1795
1945
  const userFunction = module.default || module;
1796
- const functions = Object.keys(userFunction);
1946
+ const functions = Object.keys(userFunction).filter((k) => typeof userFunction[k] === "function");
1797
1947
  if (functions.length > 0) {
1798
- console.log("\nDetected functions in function.js:");
1948
+ console.log("\nDetected functions:");
1799
1949
  for (const fn of functions) {
1800
1950
  const type = isVoiceCallback(fn) ? "voice" : "custom";
1801
- console.log(` - ${fn} (${type})`);
1951
+ console.log(` ${fn} (${type}): POST http://localhost:${port}/${fn}`);
1802
1952
  }
1803
1953
  }
1804
1954
  } catch {
@@ -1838,6 +1988,17 @@ async function main() {
1838
1988
  return module.default || module;
1839
1989
  };
1840
1990
  await loadUserFunction();
1991
+ let setupResult;
1992
+ const rawModule = await import(pathToFileURL2(findFunctionPath3()).href);
1993
+ const setupFn = rawModule.setup || rawModule.default?.setup;
1994
+ if (typeof setupFn === "function") {
1995
+ const { runtime, result } = createSinchRuntime();
1996
+ await Promise.resolve(setupFn(runtime));
1997
+ setupResult = result;
1998
+ if (verbose) {
1999
+ console.log(`[SETUP] Registered ${result.startupHandlers.length} startup handler(s), ${result.wsHandlers.size} WebSocket endpoint(s)`);
2000
+ }
2001
+ }
1841
2002
  setupRequestHandler(app, {
1842
2003
  landingPageEnabled,
1843
2004
  authConfig: userAuthConfig,
@@ -1866,17 +2027,22 @@ async function main() {
1866
2027
  });
1867
2028
  });
1868
2029
  displayStartupInfo(config, verbose, port);
1869
- app.listen(port, async () => {
2030
+ if (setupResult?.startupHandlers.length) {
2031
+ const context = buildLocalContext({}, config);
2032
+ for (const handler of setupResult.startupHandlers) {
2033
+ await handler(context);
2034
+ }
2035
+ console.log(`[SETUP] Startup hooks completed`);
2036
+ }
2037
+ const server = app.listen(port, async () => {
1870
2038
  console.log(`Function server running on http://localhost:${port}`);
1871
- console.log("\nTest endpoints:");
1872
- console.log(` ICE: POST http://localhost:${port}/ice`);
1873
- console.log(` PIE: POST http://localhost:${port}/pie`);
1874
- console.log(` ACE: POST http://localhost:${port}/ace`);
1875
- console.log(` DICE: POST http://localhost:${port}/dice`);
1876
- await displayDetectedFunctions();
2039
+ await displayDetectedFunctions(port);
1877
2040
  if (!verbose) {
1878
2041
  console.log("\nTip: Set VERBOSE=true or use --verbose for detailed output");
1879
2042
  }
2043
+ if (setupResult?.wsHandlers.size) {
2044
+ installWebSocketHandlers(server, setupResult.wsHandlers, console.log);
2045
+ }
1880
2046
  if (process.env.SINCH_TUNNEL === "true") {
1881
2047
  console.log("\nStarting tunnel...");
1882
2048
  const tunnelClient = new TunnelClient(port);