create-anywherescada-app 0.0.1

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.
package/dist/index.js ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ import { resolve, dirname, join } from "node:path";
3
+ import { existsSync, readFileSync, writeFileSync, renameSync, cpSync } from "node:fs";
4
+ import { execSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createInterface } from "node:readline";
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+ const projectName = process.argv[2];
10
+ if (!projectName) {
11
+ console.error("Usage: npx create-anywherescada-app <project-name>");
12
+ process.exit(1);
13
+ }
14
+ const targetDir = resolve(process.cwd(), projectName);
15
+ if (existsSync(targetDir)) {
16
+ console.error(`Error: Directory "${projectName}" already exists.`);
17
+ process.exit(1);
18
+ }
19
+ function prompt(question) {
20
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
21
+ return new Promise((resolve) => {
22
+ rl.question(question, (answer) => {
23
+ rl.close();
24
+ resolve(answer.trim());
25
+ });
26
+ });
27
+ }
28
+ async function main() {
29
+ console.log(`\nCreating AnywhereScada app in ${targetDir}...\n`);
30
+ // Copy template
31
+ const templateDir = join(__dirname, "..", "template");
32
+ cpSync(templateDir, targetDir, { recursive: true });
33
+ // Rename dotfiles
34
+ const renames = [
35
+ ["gitignore", ".gitignore"],
36
+ ["env.example", ".env"],
37
+ ["npmrc", ".npmrc"],
38
+ ];
39
+ for (const [from, to] of renames) {
40
+ const fromPath = join(targetDir, from);
41
+ const toPath = join(targetDir, to);
42
+ if (existsSync(fromPath)) {
43
+ renameSync(fromPath, toPath);
44
+ }
45
+ }
46
+ // Prompt for API key
47
+ const apiKey = await prompt("Enter your AnywhereScada API key (or press Enter to skip): ");
48
+ if (apiKey) {
49
+ const envPath = join(targetDir, ".env");
50
+ let envContent = readFileSync(envPath, "utf-8");
51
+ envContent = envContent.replace("ANYWHERESCADA_API_KEY=", `ANYWHERESCADA_API_KEY=${apiKey}`);
52
+ writeFileSync(envPath, envContent);
53
+ }
54
+ // Install dependencies
55
+ console.log("\nInstalling dependencies...\n");
56
+ execSync("npm install", { cwd: targetDir, stdio: "inherit" });
57
+ // Initialize git
58
+ try {
59
+ execSync("git init", { cwd: targetDir, stdio: "ignore" });
60
+ execSync("git add -A", { cwd: targetDir, stdio: "ignore" });
61
+ execSync('git commit -m "Initial commit from create-anywherescada-app"', {
62
+ cwd: targetDir,
63
+ stdio: "ignore",
64
+ });
65
+ }
66
+ catch {
67
+ // git not available, skip
68
+ }
69
+ console.log(`
70
+ Done! Your AnywhereScada app is ready.
71
+
72
+ cd ${projectName}
73
+ npm run dev
74
+
75
+ ${apiKey ? "Your API key has been saved to .env" : "Set your API key in .env:\n ANYWHERESCADA_API_KEY=as_live_..."}
76
+
77
+ Learn more: https://anywherescada.com
78
+ `);
79
+ }
80
+ main().catch((err) => {
81
+ console.error(err);
82
+ process.exit(1);
83
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "create-anywherescada-app",
3
+ "version": "0.0.1",
4
+ "description": "Create a SvelteKit app connected to AnywhereScada",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-anywherescada-app": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "template"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "keywords": [
18
+ "svelte",
19
+ "sveltekit",
20
+ "scada",
21
+ "anywherescada",
22
+ "iot",
23
+ "sparkplug"
24
+ ],
25
+ "author": "Joy Automation",
26
+ "license": "MIT",
27
+ "devDependencies": {
28
+ "@types/node": "^22.0.0",
29
+ "typescript": "^5.8.0"
30
+ },
31
+ "engines": {
32
+ "node": ">=18.0.0"
33
+ }
34
+ }
@@ -0,0 +1 @@
1
+ ANYWHERESCADA_API_KEY=
@@ -0,0 +1,4 @@
1
+ node_modules/
2
+ .svelte-kit/
3
+ build/
4
+ .env
package/template/npmrc ADDED
@@ -0,0 +1 @@
1
+ legacy-peer-deps=true
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "my-anywherescada-app",
3
+ "private": true,
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite dev",
8
+ "build": "vite build",
9
+ "preview": "vite preview",
10
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
11
+ },
12
+ "devDependencies": {
13
+ "@sveltejs/adapter-auto": "^6.0.0",
14
+ "@sveltejs/kit": "^2.22.0",
15
+ "@sveltejs/vite-plugin-svelte": "^6.0.0",
16
+ "svelte": "^5.35.0",
17
+ "svelte-check": "^4.2.0",
18
+ "@types/ws": "^8.18.0",
19
+ "typescript": "^5.8.0",
20
+ "vite": "^7.0.0"
21
+ },
22
+ "dependencies": {
23
+ "@fontsource/space-grotesk": "^5.2.8",
24
+ "@joyautomation/salt": "^0.0.21",
25
+ "graphql-ws": "^6.0.0",
26
+ "sass": "^1.89.0",
27
+ "ws": "^8.19.0"
28
+ }
29
+ }
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <link rel="icon" href="%sveltekit.assets%/favicon.png" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ %sveltekit.head%
8
+ </head>
9
+ <body data-sveltekit-preload-data="hover" class="%theme%">
10
+ <div style="display: contents">%sveltekit.body%</div>
11
+ </body>
12
+ </html>
@@ -0,0 +1,27 @@
1
+ @use '@fontsource/space-grotesk';
2
+
3
+ :root {
4
+ font-family: 'Space Grotesk', sans-serif;
5
+ }
6
+
7
+ header {
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: space-between;
11
+ padding: calc(var(--spacing-unit) * 4) calc(var(--spacing-unit) * 6);
12
+ background: var(--theme-neutral-100);
13
+ border-bottom: 1px solid var(--theme-neutral-300);
14
+
15
+ h1 {
16
+ font-size: var(--text-xl);
17
+ font-weight: 700;
18
+ color: var(--theme-text);
19
+ margin: 0;
20
+ }
21
+ }
22
+
23
+ main {
24
+ padding: calc(var(--spacing-unit) * 6);
25
+ max-width: 1400px;
26
+ margin: 0 auto;
27
+ }
@@ -0,0 +1,11 @@
1
+ import type { Handle } from '@sveltejs/kit';
2
+
3
+ export const handle: Handle = async ({ event, resolve }) => {
4
+ const theme = event.cookies.get('theme') ?? 'themeLight';
5
+ const validThemes = ['themeLight', 'themeDark'];
6
+ const safeTheme = validThemes.includes(theme) ? theme : 'themeLight';
7
+
8
+ return resolve(event, {
9
+ transformPageChunk: ({ html }) => html.replace('%theme%', safeTheme)
10
+ });
11
+ };
@@ -0,0 +1,62 @@
1
+ const API_URL = 'https://api.anywherescada.com/graphql';
2
+
3
+ export async function query<T>(
4
+ apiKey: string,
5
+ gql: string,
6
+ variables?: Record<string, unknown>
7
+ ): Promise<T> {
8
+ const response = await fetch(API_URL, {
9
+ method: 'POST',
10
+ headers: {
11
+ 'Content-Type': 'application/json',
12
+ Authorization: `Bearer ${apiKey}`
13
+ },
14
+ body: JSON.stringify({ query: gql, variables })
15
+ });
16
+
17
+ if (!response.ok) {
18
+ throw new Error(`API request failed: ${response.status} ${response.statusText}`);
19
+ }
20
+
21
+ const json = await response.json();
22
+ if (json.errors) {
23
+ throw new Error(
24
+ `GraphQL errors: ${json.errors.map((e: { message: string }) => e.message).join(', ')}`
25
+ );
26
+ }
27
+
28
+ return json.data;
29
+ }
30
+
31
+ export const QUERIES = {
32
+ groups: `
33
+ query GetGroups {
34
+ groups {
35
+ id
36
+ nodes {
37
+ id
38
+ metrics { id name value type scanRate }
39
+ devices {
40
+ id
41
+ metrics { id name value type scanRate }
42
+ }
43
+ }
44
+ }
45
+ }
46
+ `
47
+ };
48
+
49
+ export const SUBSCRIPTIONS = {
50
+ metricUpdate: `
51
+ subscription {
52
+ metricUpdate {
53
+ groupId
54
+ nodeId
55
+ deviceId
56
+ metricId
57
+ value
58
+ timestamp
59
+ }
60
+ }
61
+ `
62
+ };
@@ -0,0 +1,39 @@
1
+ export type SparkplugMetricProperty = {
2
+ id: string;
3
+ type: string;
4
+ value: string;
5
+ };
6
+
7
+ export type SparkplugMetric = {
8
+ id: string;
9
+ name: string;
10
+ value: string;
11
+ type: string;
12
+ scanRate: number;
13
+ properties: SparkplugMetricProperty[];
14
+ };
15
+
16
+ export type SparkplugDevice = {
17
+ id: string;
18
+ metrics: SparkplugMetric[];
19
+ };
20
+
21
+ export type SparkplugNode = {
22
+ id: string;
23
+ metrics: SparkplugMetric[];
24
+ devices: SparkplugDevice[];
25
+ };
26
+
27
+ export type SparkplugGroup = {
28
+ id: string;
29
+ nodes: SparkplugNode[];
30
+ };
31
+
32
+ export type MetricUpdate = {
33
+ groupId: string;
34
+ nodeId: string;
35
+ deviceId: string;
36
+ metricId: string;
37
+ value: string;
38
+ timestamp: number;
39
+ };
@@ -0,0 +1,7 @@
1
+ import type { LayoutServerLoad } from './$types';
2
+
3
+ export const load: LayoutServerLoad = ({ cookies }) => {
4
+ return {
5
+ theme: cookies.get('theme') ?? 'themeLight'
6
+ };
7
+ };
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ import '@joyautomation/salt/styles.scss';
3
+ import '../app.scss';
4
+ import { Toast, ThemeButton } from '@joyautomation/salt';
5
+
6
+ const { data, children } = $props();
7
+ </script>
8
+
9
+ <header>
10
+ <h1>My SCADA App</h1>
11
+ <ThemeButton theme={data.theme} />
12
+ </header>
13
+
14
+ <main>
15
+ {@render children()}
16
+ </main>
17
+
18
+ <Toast />
@@ -0,0 +1,24 @@
1
+ import type { PageServerLoad } from './$types';
2
+ import { ANYWHERESCADA_API_KEY } from '$env/static/private';
3
+ import { query, QUERIES } from '$lib/anywherescada';
4
+ import { actions as saltActions } from '@joyautomation/salt';
5
+ import type { SparkplugGroup } from '$lib/types';
6
+
7
+ const { setTheme } = saltActions;
8
+
9
+ export const load: PageServerLoad = async () => {
10
+ if (!ANYWHERESCADA_API_KEY) {
11
+ return { groups: [], error: 'ANYWHERESCADA_API_KEY is not set. Add it to your .env file.' };
12
+ }
13
+
14
+ try {
15
+ const data = await query<{ groups: SparkplugGroup[] }>(ANYWHERESCADA_API_KEY, QUERIES.groups);
16
+ return { groups: data.groups, error: null };
17
+ } catch (err) {
18
+ return { groups: [], error: err instanceof Error ? err.message : 'Failed to fetch data' };
19
+ }
20
+ };
21
+
22
+ export const actions = {
23
+ setTheme
24
+ };
@@ -0,0 +1,346 @@
1
+ <script lang="ts">
2
+ import { ChevronDown, ChevronRight } from '@joyautomation/salt/icons';
3
+ import type { SparkplugGroup, MetricUpdate } from '$lib/types';
4
+
5
+ const { data } = $props();
6
+
7
+ // svelte-ignore state_referenced_locally — initialized from server, then updated via SSE
8
+ let groups = $state<SparkplugGroup[]>(data.groups);
9
+ let connected = $state(false);
10
+ // svelte-ignore state_referenced_locally
11
+ let error = $state<string | null>(data.error);
12
+
13
+ let expandedGroups = $state<Set<string>>(new Set());
14
+ let expandedNodes = $state<Set<string>>(new Set());
15
+ let expandedDevices = $state<Set<string>>(new Set());
16
+
17
+ function toggleSet(set: Set<string>, key: string): Set<string> {
18
+ const next = new Set(set);
19
+ if (next.has(key)) {
20
+ next.delete(key);
21
+ } else {
22
+ next.add(key);
23
+ }
24
+ return next;
25
+ }
26
+
27
+ function applyUpdate(update: MetricUpdate) {
28
+ groups = groups.map((group) => {
29
+ if (group.id !== update.groupId) return group;
30
+ return {
31
+ ...group,
32
+ nodes: group.nodes.map((node) => {
33
+ if (node.id !== update.nodeId) return node;
34
+ if (!update.deviceId) {
35
+ return {
36
+ ...node,
37
+ metrics: node.metrics.map((m) =>
38
+ m.id === update.metricId ? { ...m, value: update.value } : m
39
+ )
40
+ };
41
+ }
42
+ return {
43
+ ...node,
44
+ devices: node.devices.map((device) => {
45
+ if (device.id !== update.deviceId) return device;
46
+ return {
47
+ ...device,
48
+ metrics: device.metrics.map((m) =>
49
+ m.id === update.metricId ? { ...m, value: update.value } : m
50
+ )
51
+ };
52
+ })
53
+ };
54
+ })
55
+ };
56
+ });
57
+ }
58
+
59
+ $effect(() => {
60
+ if (error) return;
61
+
62
+ const eventSource = new EventSource('/api/subscribe');
63
+
64
+ eventSource.onopen = () => {
65
+ connected = true;
66
+ };
67
+
68
+ eventSource.addEventListener('metricUpdate', (event) => {
69
+ const updates: MetricUpdate[] = JSON.parse(event.data);
70
+ for (const update of updates) {
71
+ applyUpdate(update);
72
+ }
73
+ });
74
+
75
+ eventSource.onerror = () => {
76
+ connected = false;
77
+ };
78
+
79
+ return () => {
80
+ eventSource.close();
81
+ };
82
+ });
83
+ </script>
84
+
85
+ <div class="dashboard">
86
+ <div class="status-bar">
87
+ <h2>Live Metrics</h2>
88
+ <div class="connection-status">
89
+ <span class="status-dot" class:connected></span>
90
+ {connected ? 'Connected' : 'Connecting...'}
91
+ </div>
92
+ </div>
93
+
94
+ {#if error}
95
+ <div class="error-banner">
96
+ <p>{error}</p>
97
+ </div>
98
+ {/if}
99
+
100
+ {#if groups.length === 0 && !error}
101
+ <div class="empty-state">
102
+ <p>No data available. Make sure your space has active Sparkplug nodes publishing data.</p>
103
+ </div>
104
+ {/if}
105
+
106
+ {#each groups as group}
107
+ <div class="group-card">
108
+ <button class="group-header" onclick={() => (expandedGroups = toggleSet(expandedGroups, group.id))}>
109
+ {#if expandedGroups.has(group.id)}
110
+ <ChevronDown />
111
+ {:else}
112
+ <ChevronRight />
113
+ {/if}
114
+ <span class="group-name">{group.id}</span>
115
+ <span class="badge">{group.nodes.length} node{group.nodes.length !== 1 ? 's' : ''}</span>
116
+ </button>
117
+
118
+ {#if expandedGroups.has(group.id)}
119
+ <div class="group-content">
120
+ {#each group.nodes as node}
121
+ {@const nodeKey = `${group.id}/${node.id}`}
122
+ <div class="node-section">
123
+ <button class="node-header" onclick={() => (expandedNodes = toggleSet(expandedNodes, nodeKey))}>
124
+ {#if expandedNodes.has(nodeKey)}
125
+ <ChevronDown />
126
+ {:else}
127
+ <ChevronRight />
128
+ {/if}
129
+ <span class="node-name">{node.id}</span>
130
+ </button>
131
+
132
+ {#if expandedNodes.has(nodeKey)}
133
+ <div class="node-content">
134
+ {#if node.metrics.length > 0}
135
+ <div class="metrics-section">
136
+ <h4>Node Metrics</h4>
137
+ <div class="metrics-grid">
138
+ {#each node.metrics as metric}
139
+ <div class="metric-card">
140
+ <span class="metric-name">{metric.name}</span>
141
+ <span class="metric-value">{metric.value}</span>
142
+ <span class="metric-type">{metric.type}</span>
143
+ </div>
144
+ {/each}
145
+ </div>
146
+ </div>
147
+ {/if}
148
+
149
+ {#each node.devices as device}
150
+ {@const deviceKey = `${group.id}/${node.id}/${device.id}`}
151
+ <div class="device-section">
152
+ <button class="device-header" onclick={() => (expandedDevices = toggleSet(expandedDevices, deviceKey))}>
153
+ {#if expandedDevices.has(deviceKey)}
154
+ <ChevronDown />
155
+ {:else}
156
+ <ChevronRight />
157
+ {/if}
158
+ <span class="device-name">{device.id}</span>
159
+ </button>
160
+
161
+ {#if expandedDevices.has(deviceKey)}
162
+ <div class="metrics-grid">
163
+ {#each device.metrics as metric}
164
+ <div class="metric-card">
165
+ <span class="metric-name">{metric.name}</span>
166
+ <span class="metric-value">{metric.value}</span>
167
+ <span class="metric-type">{metric.type}</span>
168
+ </div>
169
+ {/each}
170
+ </div>
171
+ {/if}
172
+ </div>
173
+ {/each}
174
+ </div>
175
+ {/if}
176
+ </div>
177
+ {/each}
178
+ </div>
179
+ {/if}
180
+ </div>
181
+ {/each}
182
+ </div>
183
+
184
+ <style lang="scss">
185
+ .dashboard {
186
+ display: flex;
187
+ flex-direction: column;
188
+ gap: calc(var(--spacing-unit) * 4);
189
+ }
190
+
191
+ .status-bar {
192
+ display: flex;
193
+ justify-content: space-between;
194
+ align-items: center;
195
+
196
+ h2 {
197
+ margin: 0;
198
+ font-size: var(--text-2xl);
199
+ color: var(--theme-text);
200
+ }
201
+ }
202
+
203
+ .connection-status {
204
+ display: flex;
205
+ align-items: center;
206
+ gap: calc(var(--spacing-unit) * 2);
207
+ font-size: var(--text-sm);
208
+ color: var(--theme-neutral-600);
209
+ }
210
+
211
+ .status-dot {
212
+ width: 8px;
213
+ height: 8px;
214
+ border-radius: 50%;
215
+ background: var(--red-500);
216
+
217
+ &.connected {
218
+ background: var(--green-500);
219
+ }
220
+ }
221
+
222
+ .error-banner {
223
+ padding: calc(var(--spacing-unit) * 4);
224
+ background: var(--theme-error-100);
225
+ border: 1px solid var(--theme-error-300);
226
+ border-radius: var(--rounded-lg);
227
+ color: var(--theme-error-700);
228
+
229
+ p {
230
+ margin: 0;
231
+ }
232
+ }
233
+
234
+ .empty-state {
235
+ text-align: center;
236
+ padding: calc(var(--spacing-unit) * 12);
237
+ color: var(--theme-neutral-500);
238
+ }
239
+
240
+ .group-card {
241
+ background: var(--theme-neutral-50);
242
+ border: 1px solid var(--theme-neutral-300);
243
+ border-radius: var(--rounded-lg);
244
+ overflow: hidden;
245
+ }
246
+
247
+ .group-header,
248
+ .node-header,
249
+ .device-header {
250
+ display: flex;
251
+ align-items: center;
252
+ gap: calc(var(--spacing-unit) * 2);
253
+ width: 100%;
254
+ padding: calc(var(--spacing-unit) * 3) calc(var(--spacing-unit) * 4);
255
+ border: none;
256
+ background: transparent;
257
+ cursor: pointer;
258
+ font-size: var(--text-base);
259
+ color: var(--theme-text);
260
+ text-align: left;
261
+
262
+ &:hover {
263
+ background: var(--theme-neutral-200);
264
+ }
265
+ }
266
+
267
+ .group-header {
268
+ background: var(--theme-neutral-100);
269
+ font-weight: 600;
270
+ }
271
+
272
+ .badge {
273
+ margin-left: auto;
274
+ font-size: var(--text-xs);
275
+ padding: calc(var(--spacing-unit) * 0.5) calc(var(--spacing-unit) * 2);
276
+ background: var(--theme-primary);
277
+ color: white;
278
+ border-radius: var(--rounded-full);
279
+ font-weight: 500;
280
+ }
281
+
282
+ .group-content {
283
+ padding: calc(var(--spacing-unit) * 2);
284
+ }
285
+
286
+ .node-section {
287
+ border: 1px solid var(--theme-neutral-200);
288
+ border-radius: var(--rounded-md);
289
+ margin-bottom: calc(var(--spacing-unit) * 2);
290
+ overflow: hidden;
291
+ }
292
+
293
+ .node-content {
294
+ padding: calc(var(--spacing-unit) * 3);
295
+ }
296
+
297
+ .metrics-section h4 {
298
+ margin: 0 0 calc(var(--spacing-unit) * 2) 0;
299
+ font-size: var(--text-sm);
300
+ color: var(--theme-neutral-500);
301
+ text-transform: uppercase;
302
+ letter-spacing: 0.05em;
303
+ }
304
+
305
+ .device-section {
306
+ margin-top: calc(var(--spacing-unit) * 2);
307
+ border: 1px solid var(--theme-neutral-200);
308
+ border-radius: var(--rounded-md);
309
+ overflow: hidden;
310
+ }
311
+
312
+ .metrics-grid {
313
+ display: grid;
314
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
315
+ gap: calc(var(--spacing-unit) * 2);
316
+ padding: calc(var(--spacing-unit) * 2);
317
+ }
318
+
319
+ .metric-card {
320
+ display: flex;
321
+ flex-direction: column;
322
+ gap: calc(var(--spacing-unit) * 1);
323
+ padding: calc(var(--spacing-unit) * 3);
324
+ background: var(--theme-neutral-100);
325
+ border-radius: var(--rounded-md);
326
+ border: 1px solid var(--theme-neutral-200);
327
+ }
328
+
329
+ .metric-name {
330
+ font-size: var(--text-sm);
331
+ color: var(--theme-neutral-600);
332
+ font-weight: 500;
333
+ }
334
+
335
+ .metric-value {
336
+ font-size: var(--text-xl);
337
+ font-weight: 700;
338
+ color: var(--theme-text);
339
+ font-variant-numeric: tabular-nums;
340
+ }
341
+
342
+ .metric-type {
343
+ font-size: var(--text-xs);
344
+ color: var(--theme-neutral-400);
345
+ }
346
+ </style>
@@ -0,0 +1,67 @@
1
+ import type { RequestHandler } from './$types';
2
+ import { ANYWHERESCADA_API_KEY } from '$env/static/private';
3
+ import { createClient } from 'graphql-ws';
4
+ import WebSocket from 'ws';
5
+ import { SUBSCRIPTIONS } from '$lib/anywherescada';
6
+
7
+ export const GET: RequestHandler = () => {
8
+ if (!ANYWHERESCADA_API_KEY) {
9
+ return new Response('API key not configured', { status: 500 });
10
+ }
11
+
12
+ let cleanup: (() => void) | undefined;
13
+
14
+ const stream = new ReadableStream({
15
+ start(controller) {
16
+ const encoder = new TextEncoder();
17
+
18
+ const client = createClient({
19
+ url: `wss://api.anywherescada.com/graphql?token=${ANYWHERESCADA_API_KEY}`,
20
+ webSocketImpl: WebSocket
21
+ });
22
+
23
+ const unsubscribe = client.subscribe(
24
+ { query: SUBSCRIPTIONS.metricUpdate },
25
+ {
26
+ next: (result) => {
27
+ if (result.data?.metricUpdate) {
28
+ const data = `event: metricUpdate\ndata: ${JSON.stringify(result.data.metricUpdate)}\n\n`;
29
+ controller.enqueue(encoder.encode(data));
30
+ }
31
+ },
32
+ error: (err) => {
33
+ console.error('Subscription error:', err);
34
+ try {
35
+ controller.close();
36
+ } catch {
37
+ // Already closed
38
+ }
39
+ },
40
+ complete: () => {
41
+ try {
42
+ controller.close();
43
+ } catch {
44
+ // Already closed
45
+ }
46
+ }
47
+ }
48
+ );
49
+
50
+ cleanup = () => {
51
+ unsubscribe();
52
+ client.dispose();
53
+ };
54
+ },
55
+ cancel() {
56
+ cleanup?.();
57
+ }
58
+ });
59
+
60
+ return new Response(stream, {
61
+ headers: {
62
+ 'Content-Type': 'text/event-stream',
63
+ 'Cache-Control': 'no-cache',
64
+ Connection: 'keep-alive'
65
+ }
66
+ });
67
+ };
@@ -0,0 +1,12 @@
1
+ import adapter from '@sveltejs/adapter-auto';
2
+ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
3
+
4
+ /** @type {import('@sveltejs/kit').Config} */
5
+ const config = {
6
+ preprocess: [vitePreprocess()],
7
+ kit: {
8
+ adapter: adapter()
9
+ }
10
+ };
11
+
12
+ export default config;
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "./.svelte-kit/tsconfig.json",
3
+ "compilerOptions": {
4
+ "allowJs": true,
5
+ "checkJs": true,
6
+ "esModuleInterop": true,
7
+ "forceConsistentCasingInFileNames": true,
8
+ "resolveJsonModule": true,
9
+ "skipLibCheck": true,
10
+ "sourceMap": true,
11
+ "strict": true,
12
+ "moduleResolution": "bundler"
13
+ }
14
+ }
@@ -0,0 +1,6 @@
1
+ import { sveltekit } from '@sveltejs/kit/vite';
2
+ import { defineConfig } from 'vite';
3
+
4
+ export default defineConfig({
5
+ plugins: [sveltekit()]
6
+ });