mrmd-server 0.1.0 → 0.1.2
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/LICENSE +21 -0
- package/bin/cli.js +4 -20
- package/package.json +20 -3
- package/src/api/bash.js +72 -189
- package/src/api/file.js +26 -20
- package/src/api/index.js +5 -0
- package/src/api/notebook.js +290 -0
- package/src/api/project.js +178 -12
- package/src/api/pty.js +73 -293
- package/src/api/r.js +337 -0
- package/src/api/session.js +96 -251
- package/src/api/settings.js +782 -0
- package/src/api/system.js +199 -1
- package/src/server.js +133 -8
- package/src/services.js +42 -0
- package/src/sync-manager.js +223 -0
- package/static/favicon.png +0 -0
- package/static/http-shim.js +172 -3
- package/static/index.html +1 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Server Manager for mrmd-server
|
|
3
|
+
*
|
|
4
|
+
* Ported from mrmd-electron/main.js to provide dynamic per-project sync servers.
|
|
5
|
+
* Allows mrmd-server to handle files from any project, not just a fixed projectDir.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
|
|
15
|
+
// Import utilities from mrmd-electron
|
|
16
|
+
import { findFreePort, waitForPort, isProcessAlive } from 'mrmd-electron/src/utils/index.js';
|
|
17
|
+
import { SYNC_SERVER_MEMORY_MB, DIR_HASH_LENGTH } from 'mrmd-electron/src/config.js';
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
20
|
+
const __dirname = path.dirname(__filename);
|
|
21
|
+
|
|
22
|
+
// Track active sync servers by directory hash
|
|
23
|
+
const syncServers = new Map();
|
|
24
|
+
|
|
25
|
+
// Event listeners for sync death notifications
|
|
26
|
+
const syncDeathListeners = new Set();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Hash a directory path to a short, filesystem-safe string
|
|
30
|
+
*/
|
|
31
|
+
function computeDirHash(dir) {
|
|
32
|
+
return crypto.createHash('sha256').update(path.resolve(dir)).digest('hex').slice(0, DIR_HASH_LENGTH);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the path to an mrmd package's CLI script
|
|
37
|
+
* In dev mode: Returns path to source CLI in sibling directory
|
|
38
|
+
*/
|
|
39
|
+
function resolvePackageBin(packageName, binPath) {
|
|
40
|
+
// Try sibling directory (for monorepo development)
|
|
41
|
+
const siblingPath = path.join(path.dirname(path.dirname(__dirname)), packageName, binPath);
|
|
42
|
+
if (fs.existsSync(siblingPath)) {
|
|
43
|
+
return siblingPath;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Try node_modules
|
|
47
|
+
try {
|
|
48
|
+
const packageJson = path.dirname(require.resolve(`${packageName}/package.json`));
|
|
49
|
+
return path.join(packageJson, binPath);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
// Fallback for ESM - look relative to mrmd-server
|
|
52
|
+
const nmPath = path.join(__dirname, '..', 'node_modules', packageName, binPath);
|
|
53
|
+
if (fs.existsSync(nmPath)) {
|
|
54
|
+
return nmPath;
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`Cannot resolve ${packageName}: ${e.message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Notify all registered listeners that a sync server died
|
|
62
|
+
*/
|
|
63
|
+
function notifySyncDied(projectDir, exitCode, signal) {
|
|
64
|
+
const message = {
|
|
65
|
+
projectDir,
|
|
66
|
+
exitCode,
|
|
67
|
+
signal,
|
|
68
|
+
timestamp: new Date().toISOString(),
|
|
69
|
+
reason: exitCode === null ? 'crashed (likely OOM)' : `exited with code ${exitCode}`,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
console.error(`[sync] CRITICAL: Sync server died for ${projectDir}:`, message.reason);
|
|
73
|
+
|
|
74
|
+
for (const listener of syncDeathListeners) {
|
|
75
|
+
try {
|
|
76
|
+
listener(message);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.error('[sync] Error in death listener:', e);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Register a listener to be notified when any sync server dies
|
|
85
|
+
* @param {Function} listener - Called with {projectDir, exitCode, signal, timestamp, reason}
|
|
86
|
+
* @returns {Function} Unsubscribe function
|
|
87
|
+
*/
|
|
88
|
+
export function onSyncDeath(listener) {
|
|
89
|
+
syncDeathListeners.add(listener);
|
|
90
|
+
return () => syncDeathListeners.delete(listener);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get or start a sync server for a project directory
|
|
95
|
+
* Uses reference counting so multiple documents can share a sync server
|
|
96
|
+
*
|
|
97
|
+
* @param {string} projectDir - The project directory to sync
|
|
98
|
+
* @returns {Promise<{port: number, dir: string, refCount: number}>}
|
|
99
|
+
*/
|
|
100
|
+
export async function acquireSyncServer(projectDir) {
|
|
101
|
+
const dirHash = computeDirHash(projectDir);
|
|
102
|
+
|
|
103
|
+
// Reuse existing server if available
|
|
104
|
+
if (syncServers.has(dirHash)) {
|
|
105
|
+
const server = syncServers.get(dirHash);
|
|
106
|
+
server.refCount++;
|
|
107
|
+
console.log(`[sync] Reusing server for ${projectDir} on port ${server.port} (refCount: ${server.refCount})`);
|
|
108
|
+
return server;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check for existing server from a PID file (in case of restart)
|
|
112
|
+
const syncStatePath = path.join(os.tmpdir(), `mrmd-sync-${dirHash}`, 'server.pid');
|
|
113
|
+
try {
|
|
114
|
+
if (fs.existsSync(syncStatePath)) {
|
|
115
|
+
const pidData = JSON.parse(fs.readFileSync(syncStatePath, 'utf8'));
|
|
116
|
+
if (isProcessAlive(pidData.pid)) {
|
|
117
|
+
console.log(`[sync] Found existing server on port ${pidData.port}`);
|
|
118
|
+
const server = { proc: null, port: pidData.port, dir: projectDir, refCount: 1, owned: false };
|
|
119
|
+
syncServers.set(dirHash, server);
|
|
120
|
+
return server;
|
|
121
|
+
} else {
|
|
122
|
+
fs.unlinkSync(syncStatePath);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch (e) {
|
|
126
|
+
// Ignore errors reading PID file
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Start a new sync server
|
|
130
|
+
const port = await findFreePort();
|
|
131
|
+
console.log(`[sync] Starting server for ${projectDir} on port ${port}...`);
|
|
132
|
+
|
|
133
|
+
const syncCliPath = resolvePackageBin('mrmd-sync', 'bin/cli.js');
|
|
134
|
+
const nodeArgs = [
|
|
135
|
+
`--max-old-space-size=${SYNC_SERVER_MEMORY_MB}`,
|
|
136
|
+
syncCliPath,
|
|
137
|
+
'--port', port.toString(),
|
|
138
|
+
'--i-know-what-i-am-doing',
|
|
139
|
+
projectDir,
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
const proc = spawn('node', nodeArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
143
|
+
proc.expectedExit = false;
|
|
144
|
+
|
|
145
|
+
proc.stdout.on('data', (d) => console.log(`[sync:${port}]`, d.toString().trim()));
|
|
146
|
+
proc.stderr.on('data', (d) => console.error(`[sync:${port}]`, d.toString().trim()));
|
|
147
|
+
|
|
148
|
+
// Handle unexpected exits (data loss prevention)
|
|
149
|
+
proc.on('exit', (code, signal) => {
|
|
150
|
+
console.log(`[sync:${port}] Exited with code ${code}, signal ${signal}`);
|
|
151
|
+
syncServers.delete(dirHash);
|
|
152
|
+
|
|
153
|
+
if (!proc.expectedExit) {
|
|
154
|
+
notifySyncDied(projectDir, code, signal);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await waitForPort(port);
|
|
159
|
+
|
|
160
|
+
const server = { proc, port, dir: projectDir, refCount: 1, owned: true };
|
|
161
|
+
syncServers.set(dirHash, server);
|
|
162
|
+
return server;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Release a sync server reference
|
|
167
|
+
* If refCount reaches 0, the server is stopped
|
|
168
|
+
*
|
|
169
|
+
* @param {string} projectDir - The project directory
|
|
170
|
+
*/
|
|
171
|
+
export function releaseSyncServer(projectDir) {
|
|
172
|
+
const dirHash = computeDirHash(projectDir);
|
|
173
|
+
const server = syncServers.get(dirHash);
|
|
174
|
+
if (!server) return;
|
|
175
|
+
|
|
176
|
+
server.refCount--;
|
|
177
|
+
console.log(`[sync] Released server for ${projectDir} (refCount: ${server.refCount})`);
|
|
178
|
+
|
|
179
|
+
if (server.refCount <= 0 && server.owned && server.proc) {
|
|
180
|
+
console.log(`[sync] Stopping server for ${projectDir}`);
|
|
181
|
+
server.proc.expectedExit = true;
|
|
182
|
+
server.proc.kill('SIGTERM');
|
|
183
|
+
syncServers.delete(dirHash);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get the sync server for a project if one is running
|
|
189
|
+
*
|
|
190
|
+
* @param {string} projectDir - The project directory
|
|
191
|
+
* @returns {Object|null} The server info or null
|
|
192
|
+
*/
|
|
193
|
+
export function getSyncServer(projectDir) {
|
|
194
|
+
const dirHash = computeDirHash(projectDir);
|
|
195
|
+
return syncServers.get(dirHash) || null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* List all active sync servers
|
|
200
|
+
* @returns {Array<{dir: string, port: number, refCount: number, owned: boolean}>}
|
|
201
|
+
*/
|
|
202
|
+
export function listSyncServers() {
|
|
203
|
+
return Array.from(syncServers.values()).map(s => ({
|
|
204
|
+
dir: s.dir,
|
|
205
|
+
port: s.port,
|
|
206
|
+
refCount: s.refCount,
|
|
207
|
+
owned: s.owned,
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Stop all sync servers (for shutdown)
|
|
213
|
+
*/
|
|
214
|
+
export function stopAllSyncServers() {
|
|
215
|
+
for (const [hash, server] of syncServers) {
|
|
216
|
+
if (server.owned && server.proc) {
|
|
217
|
+
console.log(`[sync] Stopping server for ${server.dir}`);
|
|
218
|
+
server.proc.expectedExit = true;
|
|
219
|
+
server.proc.kill('SIGTERM');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
syncServers.clear();
|
|
223
|
+
}
|
|
Binary file
|
package/static/http-shim.js
CHANGED
|
@@ -130,6 +130,12 @@
|
|
|
130
130
|
|
|
131
131
|
getAi: () => GET('/api/system/ai'),
|
|
132
132
|
|
|
133
|
+
// System info and uv management
|
|
134
|
+
system: {
|
|
135
|
+
info: () => GET('/api/system/info'),
|
|
136
|
+
ensureUv: () => POST('/api/system/ensure-uv', {}),
|
|
137
|
+
},
|
|
138
|
+
|
|
133
139
|
// ========================================================================
|
|
134
140
|
// Shell (stubs for browser)
|
|
135
141
|
// ========================================================================
|
|
@@ -191,6 +197,9 @@
|
|
|
191
197
|
// Python management
|
|
192
198
|
// ========================================================================
|
|
193
199
|
|
|
200
|
+
createVenv: (venvPath) =>
|
|
201
|
+
POST('/api/system/create-venv', { venvPath }),
|
|
202
|
+
|
|
194
203
|
installMrmdPython: (venvPath) =>
|
|
195
204
|
POST('/api/system/install-mrmd-python', { venvPath }),
|
|
196
205
|
|
|
@@ -212,11 +221,24 @@
|
|
|
212
221
|
// ========================================================================
|
|
213
222
|
|
|
214
223
|
openFile: async (filePath) => {
|
|
215
|
-
//
|
|
216
|
-
// We'll get project info and session info separately
|
|
224
|
+
// Get project info and session info
|
|
217
225
|
const project = await window.electronAPI.project.get(filePath);
|
|
218
226
|
const session = await window.electronAPI.session.forDocument(filePath);
|
|
219
|
-
|
|
227
|
+
|
|
228
|
+
// Extract filename without extension for docName
|
|
229
|
+
const fileName = filePath.split('/').pop();
|
|
230
|
+
const docName = fileName.replace(/\.md$/, '');
|
|
231
|
+
|
|
232
|
+
// Use syncPort from project response (dynamically assigned per-project)
|
|
233
|
+
const syncPort = project?.syncPort || 4444;
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
success: true,
|
|
237
|
+
syncPort,
|
|
238
|
+
docName,
|
|
239
|
+
projectDir: project?.root || filePath.split('/').slice(0, -1).join('/'),
|
|
240
|
+
pythonPort: session?.pythonPort || null,
|
|
241
|
+
};
|
|
220
242
|
},
|
|
221
243
|
|
|
222
244
|
// ========================================================================
|
|
@@ -280,6 +302,144 @@
|
|
|
280
302
|
forDocument: (documentPath) => POST('/api/bash/for-document', { documentPath }),
|
|
281
303
|
},
|
|
282
304
|
|
|
305
|
+
// ========================================================================
|
|
306
|
+
// JULIA SESSION SERVICE
|
|
307
|
+
// ========================================================================
|
|
308
|
+
|
|
309
|
+
julia: {
|
|
310
|
+
list: () => GET('/api/julia'),
|
|
311
|
+
|
|
312
|
+
start: (config) => POST('/api/julia', { config }),
|
|
313
|
+
|
|
314
|
+
stop: (sessionName) => DELETE(`/api/julia/${encodeURIComponent(sessionName)}`),
|
|
315
|
+
|
|
316
|
+
restart: (sessionName) => POST(`/api/julia/${encodeURIComponent(sessionName)}/restart`, {}),
|
|
317
|
+
|
|
318
|
+
forDocument: (documentPath) => POST('/api/julia/for-document', { documentPath }),
|
|
319
|
+
|
|
320
|
+
isAvailable: () => GET('/api/julia/available').then(r => r.available),
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
// ========================================================================
|
|
324
|
+
// PTY SESSION SERVICE (for ```term blocks)
|
|
325
|
+
// ========================================================================
|
|
326
|
+
|
|
327
|
+
pty: {
|
|
328
|
+
list: () => GET('/api/pty'),
|
|
329
|
+
|
|
330
|
+
start: (config) => POST('/api/pty', { config }),
|
|
331
|
+
|
|
332
|
+
stop: (sessionName) => DELETE(`/api/pty/${encodeURIComponent(sessionName)}`),
|
|
333
|
+
|
|
334
|
+
restart: (sessionName) => POST(`/api/pty/${encodeURIComponent(sessionName)}/restart`, {}),
|
|
335
|
+
|
|
336
|
+
forDocument: (documentPath) => POST('/api/pty/for-document', { documentPath }),
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
// ========================================================================
|
|
340
|
+
// NOTEBOOK (JUPYTER) SERVICE
|
|
341
|
+
// ========================================================================
|
|
342
|
+
|
|
343
|
+
notebook: {
|
|
344
|
+
convert: (ipynbPath) => POST('/api/notebook/convert', { ipynbPath }),
|
|
345
|
+
|
|
346
|
+
startSync: (ipynbPath) => POST('/api/notebook/start-sync', { ipynbPath }),
|
|
347
|
+
|
|
348
|
+
stopSync: (ipynbPath) => POST('/api/notebook/stop-sync', { ipynbPath }),
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
// ========================================================================
|
|
352
|
+
// R SESSION SERVICE
|
|
353
|
+
// ========================================================================
|
|
354
|
+
|
|
355
|
+
r: {
|
|
356
|
+
list: () => GET('/api/r'),
|
|
357
|
+
|
|
358
|
+
start: (config) => POST('/api/r', { config }),
|
|
359
|
+
|
|
360
|
+
stop: (sessionName) => DELETE(`/api/r/${encodeURIComponent(sessionName)}`),
|
|
361
|
+
|
|
362
|
+
restart: (sessionName) => POST(`/api/r/${encodeURIComponent(sessionName)}/restart`, {}),
|
|
363
|
+
|
|
364
|
+
forDocument: (documentPath) => POST('/api/r/for-document', { documentPath }),
|
|
365
|
+
|
|
366
|
+
isAvailable: () => GET('/api/r/available').then(r => r.available),
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
// ========================================================================
|
|
370
|
+
// SETTINGS SERVICE
|
|
371
|
+
// ========================================================================
|
|
372
|
+
|
|
373
|
+
settings: {
|
|
374
|
+
getAll: () => GET('/api/settings'),
|
|
375
|
+
|
|
376
|
+
get: (key, defaultValue) =>
|
|
377
|
+
GET(`/api/settings/key?path=${encodeURIComponent(key)}${defaultValue !== undefined ? `&default=${encodeURIComponent(defaultValue)}` : ''}`).then(r => r.value),
|
|
378
|
+
|
|
379
|
+
set: (key, value) =>
|
|
380
|
+
POST('/api/settings/key', { key, value }).then(r => r.success),
|
|
381
|
+
|
|
382
|
+
update: (updates) =>
|
|
383
|
+
POST('/api/settings/update', { updates }).then(r => r.success),
|
|
384
|
+
|
|
385
|
+
reset: () =>
|
|
386
|
+
POST('/api/settings/reset', {}).then(r => r.success),
|
|
387
|
+
|
|
388
|
+
getApiKeys: (masked = true) =>
|
|
389
|
+
GET(`/api/settings/api-keys?masked=${masked}`),
|
|
390
|
+
|
|
391
|
+
setApiKey: (provider, key) =>
|
|
392
|
+
POST('/api/settings/api-key', { provider, key }).then(r => r.success),
|
|
393
|
+
|
|
394
|
+
getApiKey: (provider) =>
|
|
395
|
+
GET(`/api/settings/api-key/${encodeURIComponent(provider)}`).then(r => r.key),
|
|
396
|
+
|
|
397
|
+
hasApiKey: (provider) =>
|
|
398
|
+
GET(`/api/settings/api-key/${encodeURIComponent(provider)}/exists`).then(r => r.hasKey),
|
|
399
|
+
|
|
400
|
+
getApiProviders: () =>
|
|
401
|
+
GET('/api/settings/api-providers'),
|
|
402
|
+
|
|
403
|
+
getQualityLevels: () =>
|
|
404
|
+
GET('/api/settings/quality-levels'),
|
|
405
|
+
|
|
406
|
+
setQualityLevelModel: (level, model) =>
|
|
407
|
+
POST(`/api/settings/quality-level/${level}/model`, { model }).then(r => r.success),
|
|
408
|
+
|
|
409
|
+
getCustomSections: () =>
|
|
410
|
+
GET('/api/settings/custom-sections'),
|
|
411
|
+
|
|
412
|
+
addCustomSection: (name) =>
|
|
413
|
+
POST('/api/settings/custom-section', { name }),
|
|
414
|
+
|
|
415
|
+
removeCustomSection: (sectionId) =>
|
|
416
|
+
DELETE(`/api/settings/custom-section/${encodeURIComponent(sectionId)}`).then(r => r.success),
|
|
417
|
+
|
|
418
|
+
addCustomCommand: (sectionId, command) =>
|
|
419
|
+
POST('/api/settings/custom-command', { sectionId, command }),
|
|
420
|
+
|
|
421
|
+
updateCustomCommand: (sectionId, commandId, updates) =>
|
|
422
|
+
apiCall('PUT', '/api/settings/custom-command', { sectionId, commandId, updates }).then(r => r.success),
|
|
423
|
+
|
|
424
|
+
removeCustomCommand: (sectionId, commandId) =>
|
|
425
|
+
apiCall('DELETE', '/api/settings/custom-command', { sectionId, commandId }).then(r => r.success),
|
|
426
|
+
|
|
427
|
+
getAllCustomCommands: () =>
|
|
428
|
+
GET('/api/settings/custom-commands'),
|
|
429
|
+
|
|
430
|
+
getDefaults: () =>
|
|
431
|
+
GET('/api/settings/defaults'),
|
|
432
|
+
|
|
433
|
+
setDefaults: (defaults) =>
|
|
434
|
+
POST('/api/settings/defaults', defaults).then(r => r.success),
|
|
435
|
+
|
|
436
|
+
export: (includeKeys = false) =>
|
|
437
|
+
GET(`/api/settings/export?includeKeys=${includeKeys}`).then(r => r.json),
|
|
438
|
+
|
|
439
|
+
import: (json, mergeKeys = false) =>
|
|
440
|
+
POST('/api/settings/import', { json, mergeKeys }).then(r => r.success),
|
|
441
|
+
},
|
|
442
|
+
|
|
283
443
|
// ========================================================================
|
|
284
444
|
// FILE SERVICE
|
|
285
445
|
// ========================================================================
|
|
@@ -353,6 +513,15 @@
|
|
|
353
513
|
// Remove existing handlers to prevent duplicates
|
|
354
514
|
eventHandlers['sync-server-died'] = [callback];
|
|
355
515
|
},
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Register callback for OS "open with" events.
|
|
519
|
+
* In browser mode, this will never be called (no OS integration).
|
|
520
|
+
*/
|
|
521
|
+
onOpenWithFile: (callback) => {
|
|
522
|
+
// No-op in browser mode - OS file associations don't exist
|
|
523
|
+
// Could potentially be triggered via URL parameters in the future
|
|
524
|
+
},
|
|
356
525
|
};
|
|
357
526
|
|
|
358
527
|
// ==========================================================================
|
package/static/index.html
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>mrmd</title>
|
|
7
|
+
<link rel="icon" type="image/png" href="/favicon.png">
|
|
7
8
|
|
|
8
9
|
<!-- Load the HTTP shim BEFORE anything else -->
|
|
9
10
|
<script src="/http-shim.js"></script>
|