arcane-os 0.3.0 → 0.3.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/CHANGELOG.md +27 -0
- package/README.md +86 -117
- package/bin/arcane-test.mjs +170 -46
- package/browser-runtime/ai/browser-speech-artifacts.mjs +887 -909
- package/browser-runtime/ai/browser-speech-providers.mjs +96 -152
- package/browser-runtime/ai/browser-wasm-llm-provider.mjs +627 -819
- package/browser-runtime/ai/browser-wasm.mjs +24 -35
- package/browser-runtime/ai/browser-wllama-runtime.mjs +64 -316
- package/browser-runtime/ai/model-controller.mjs +584 -181
- package/browser-runtime/ai/speech-worker-client.mjs +8 -146
- package/browser-runtime/ai/speech-worker-runtime.mjs +643 -363
- package/browser-runtime/dom-event-instrumentation.mjs +55 -147
- package/browser-runtime/event-manager.mjs +239 -624
- package/package.json +5 -6
- package/runtime/arcane/components/app-bar.html +3 -15
- package/runtime/arcane/components/assistant-panel.html +10 -10
- package/runtime/arcane/components/calculator.html +1 -1
- package/runtime/arcane/components/chat.html +1359 -135
- package/runtime/arcane/components/conversation-view.html +2 -2
- package/runtime/arcane/components/document-inspector.html +11 -17
- package/runtime/arcane/components/file-manager.html +13 -56
- package/runtime/arcane/components/markdown-document.html +82 -281
- package/runtime/arcane/components/markdown-editor.html +7 -10
- package/runtime/arcane/components/media-embed.html +6 -6
- package/runtime/arcane/components/screen-capture.html +4 -4
- package/runtime/arcane/components/source-explanation.html +2 -2
- package/runtime/arcane/components/speech.html +112 -68
- package/runtime/arcane/components/terminal-workspace.html +4 -4
- package/runtime/arcane/components/theme-editor.html +1 -1
- package/runtime/arcane/components/unified-inbox.html +2 -2
- package/runtime/arcane/components/voice-transcription.html +31 -21
- package/runtime/arcane/entities/Calculation.js +2 -3
- package/runtime/arcane/entities/Chat.js +228 -43
- package/runtime/arcane/entities/Preference.js +3 -5
- package/runtime/arcane/entities/Weather.js +5 -5
- package/runtime/arcane/modules/AI.js +1050 -427
- package/runtime/arcane/modules/AIProviderRuntime.js +658 -363
- package/runtime/arcane/modules/AIResponseLength.js +9 -19
- package/runtime/arcane/modules/AIRuntimeState.js +109 -72
- package/runtime/arcane/modules/ArcaneNavigationPolicy.js +45 -32
- package/runtime/arcane/modules/BrowserTestSuite.js +78 -122
- package/runtime/arcane/modules/CalculatorEngine.js +9 -9
- package/runtime/arcane/modules/CommunicationAppController.js +3 -7
- package/runtime/arcane/modules/ComponentContracts.js +30 -32
- package/runtime/arcane/modules/ConfiguredAIChatSession.js +281 -230
- package/runtime/arcane/modules/ConversationActionItems.js +26 -59
- package/runtime/arcane/modules/ConversationClosingReport.js +34 -61
- package/runtime/arcane/modules/ConversationTimebox.js +27 -15
- package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +152 -344
- package/runtime/arcane/modules/DocumentLexicalSearch.js +25 -91
- package/runtime/arcane/modules/HTMLImport.js +54 -1
- package/runtime/arcane/modules/IsolatedModelQuestionRunner.js +40 -203
- package/runtime/arcane/modules/LocalAIReadiness.js +40 -60
- package/runtime/arcane/modules/LocalAIReadinessController.js +15 -13
- package/runtime/arcane/modules/MD.js +1 -45
- package/runtime/arcane/modules/Mail.js +51 -103
- package/runtime/arcane/modules/MailOutbox.mjs +95 -193
- package/runtime/arcane/modules/MailTransport.mjs +36 -57
- package/runtime/arcane/modules/ModelDefinition.js +22 -106
- package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +39 -101
- package/runtime/arcane/modules/PersistentAIChatSession.js +281 -18
- package/runtime/arcane/modules/PreferenceStore.js +102 -30
- package/runtime/arcane/modules/RiskSignalAnalyzer.js +8 -9
- package/runtime/arcane/modules/ScopedOPFSCache.js +7 -42
- package/runtime/arcane/modules/ScreenCapture.js +175 -128
- package/runtime/arcane/modules/SpeechPlayback.js +46 -149
- package/runtime/arcane/modules/StaticDocumentCatalog.js +173 -407
- package/runtime/arcane/modules/ToolCallRouter.js +25 -12
- package/runtime/arcane/modules/YouTubeMedia.js +6 -5
- package/schemas/arcane-app-bundle.schema.json +13 -78
- package/schemas/arcane-app.schema.json +9 -25
- package/schemas/arcane-lock.schema.json +18 -151
- package/schemas/arcane-package.schema.json +2 -16
- package/schemas/native-build-plan.schema.json +119 -122
- package/src/app-descriptor.mjs +75 -132
- package/src/application-tests.mjs +200 -0
- package/src/cli/main.mjs +27 -46
- package/src/constants.mjs +3 -4
- package/src/dev-server.mjs +30 -324
- package/src/doctor.mjs +92 -154
- package/src/dom-event-instrumentation.mjs +55 -147
- package/src/errors.mjs +2 -3
- package/src/event-manager.mjs +239 -624
- package/src/event-queue.mjs +3 -3
- package/src/import-map.mjs +273 -1028
- package/src/index.mjs +14 -16
- package/src/installed-sdk-runtime.mjs +40 -62
- package/src/integrated-provider-loader.mjs +53 -382
- package/src/mail-api.mjs +0 -2
- package/src/mail-server.mjs +224 -580
- package/src/mail.mjs +4 -10
- package/src/native-plan.mjs +163 -598
- package/src/native-provider-loader.mjs +104 -1063
- package/src/packager/core.mjs +485 -3229
- package/src/process.mjs +5 -10
- package/src/release-bundle.mjs +292 -2405
- package/src/runtime.mjs +76 -396
- package/src/scaffold.mjs +30 -80
- package/src/sdk-browser-runtime.mjs +70 -626
- package/src/source-server.mjs +588 -0
- package/src/targets/index.mjs +78 -188
- package/src/templates/workspace-template.mjs +19 -135
- package/src/testing-loader.mjs +164 -0
- package/src/testing.mjs +1 -1
- package/src/toolchain.mjs +131 -544
- package/src/update-check.mjs +26 -64
- package/src/workspace-operation-lock.mjs +139 -430
- package/src/workspace-runtime.mjs +112 -779
- package/src/workspace.mjs +40 -302
- package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +0 -218
- package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +0 -203
- package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +0 -80
- package/browser-runtime/ai/internal/sha256.mjs +0 -166
- package/docs/architecture.md +0 -344
- package/docs/compatibility.md +0 -36
- package/docs/event-manager.md +0 -294
- package/docs/platform-targets.md +0 -108
- package/docs/publishing.md +0 -201
- package/docs/reference/README.md +0 -187
- package/docs/reference/ai/browser-speech-package-authority.json +0 -835
- package/docs/reference/ai/browser-speech.md +0 -1252
- package/docs/reference/ai/browser-wasm.md +0 -530
- package/docs/reference/arcane-ollama.md +0 -288
- package/docs/reference/availability-and-normalization.md +0 -183
- package/docs/reference/behavioral-testing.md +0 -133
- package/docs/reference/cli.md +0 -779
- package/docs/reference/core/README.md +0 -62
- package/docs/reference/core/arcane-ai-contracts.md +0 -907
- package/docs/reference/core/arcane-api.md +0 -601
- package/docs/reference/core/arcane-entities.md +0 -65
- package/docs/reference/core/arcane-events.md +0 -134
- package/docs/reference/core/ollama-module.md +0 -181
- package/docs/reference/core/reference/arcane-api/ai-and-ollama.md +0 -1909
- package/docs/reference/core/reference/arcane-api/applications-terminal-capabilities.md +0 -1057
- package/docs/reference/core/reference/arcane-api/core-and-events.md +0 -320
- package/docs/reference/core/reference/arcane-api/filesystem-storage-preferences-appearance.md +0 -610
- package/docs/reference/core/reference/arcane-api/namespaces.md +0 -1157
- package/docs/reference/core/reference/arcane-api/platform-installation-users-system.md +0 -1423
- package/docs/reference/core/reference/arcane-api/session-provisioning-diagnostics-development.md +0 -315
- package/docs/reference/event-manager.md +0 -1511
- package/docs/reference/inventory/package-api.json +0 -3284
- package/docs/reference/inventory/runtime-components.json +0 -1011
- package/docs/reference/inventory/runtime-entities.json +0 -26
- package/docs/reference/inventory/runtime-modules.json +0 -1431
- package/docs/reference/mail.md +0 -316
- package/docs/reference/protocols.md +0 -677
- package/docs/reference/runtime-components.md +0 -1366
- package/docs/reference/runtime-entities.md +0 -303
- package/docs/reference/runtime-modules.md +0 -2960
- package/docs/reference/sdk-api.md +0 -6694
- package/docs/roadmap.md +0 -79
- package/docs/work-amplification.md +0 -129
- package/runtime/ARCANE_RUNTIME_RELEASE.json +0 -826
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import {createReadStream} from 'node:fs';
|
|
2
|
+
import {stat} from 'node:fs/promises';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import https from 'node:https';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {pipeline} from 'node:stream/promises';
|
|
7
|
+
import {fileURLToPath} from 'node:url';
|
|
8
|
+
|
|
9
|
+
const MIME_TYPES = new Map([
|
|
10
|
+
['.css', 'text/css; charset=utf-8'],
|
|
11
|
+
['.gif', 'image/gif'],
|
|
12
|
+
['.gguf', 'application/octet-stream'],
|
|
13
|
+
['.html', 'text/html; charset=utf-8'],
|
|
14
|
+
['.ico', 'image/x-icon'],
|
|
15
|
+
['.jpeg', 'image/jpeg'],
|
|
16
|
+
['.jpg', 'image/jpeg'],
|
|
17
|
+
['.js', 'text/javascript; charset=utf-8'],
|
|
18
|
+
['.json', 'application/json; charset=utf-8'],
|
|
19
|
+
['.map', 'application/json; charset=utf-8'],
|
|
20
|
+
['.md', 'text/markdown; charset=utf-8'],
|
|
21
|
+
['.mjs', 'text/javascript; charset=utf-8'],
|
|
22
|
+
['.modelfile', 'text/plain; charset=utf-8'],
|
|
23
|
+
['.mp3', 'audio/mpeg'],
|
|
24
|
+
['.ogg', 'audio/ogg'],
|
|
25
|
+
['.png', 'image/png'],
|
|
26
|
+
['.svg', 'image/svg+xml; charset=utf-8'],
|
|
27
|
+
['.txt', 'text/plain; charset=utf-8'],
|
|
28
|
+
['.wasm', 'application/wasm'],
|
|
29
|
+
['.wav', 'audio/wav'],
|
|
30
|
+
['.webmanifest', 'application/manifest+json; charset=utf-8'],
|
|
31
|
+
['.webp', 'image/webp'],
|
|
32
|
+
['.woff', 'font/woff'],
|
|
33
|
+
['.woff2', 'font/woff2']
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function fail(message, code = 'ARCANE_SOURCE_SERVER_OPTIONS_INVALID') {
|
|
37
|
+
const error = new Error(message);
|
|
38
|
+
error.code = code;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function throwIfAborted(signal) {
|
|
43
|
+
if (!signal?.aborted) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (signal.reason instanceof Error) {
|
|
47
|
+
throw signal.reason;
|
|
48
|
+
}
|
|
49
|
+
const error = new Error('Source server start cancelled.');
|
|
50
|
+
error.code = 'ARCANE_CANCELLED';
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeHost(value) {
|
|
55
|
+
const host = String(value ?? '127.0.0.1').trim();
|
|
56
|
+
if (!host) {
|
|
57
|
+
fail('Source server host must be a nonempty string.');
|
|
58
|
+
}
|
|
59
|
+
return host;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizePort(value) {
|
|
63
|
+
const port = Number(value ?? 0);
|
|
64
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
65
|
+
fail('Source server port must be an integer from 0 through 65535.');
|
|
66
|
+
}
|
|
67
|
+
return port;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function pathSegments(value, fieldName) {
|
|
71
|
+
const text = String(value);
|
|
72
|
+
if (!text || text.includes('\\') || /[\x00-\x1f\x7f]/u.test(text)) {
|
|
73
|
+
fail(`${fieldName} must use a nonempty URL path without control characters or backslashes.`);
|
|
74
|
+
}
|
|
75
|
+
const segments = text.split('/');
|
|
76
|
+
if (segments.some(function invalidSegment(segment) {
|
|
77
|
+
return !segment || segment === '.' || segment === '..';
|
|
78
|
+
})) {
|
|
79
|
+
fail(`${fieldName} contains an invalid URL path segment.`);
|
|
80
|
+
}
|
|
81
|
+
return segments;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeUrlPath(value, fieldName, preserveTrailingSlash = false) {
|
|
85
|
+
const raw = String(value ?? '/');
|
|
86
|
+
if (!raw.startsWith('/') || raw.includes('?') || raw.includes('#')) {
|
|
87
|
+
fail(`${fieldName} must be an absolute URL path without a query or fragment.`);
|
|
88
|
+
}
|
|
89
|
+
if (raw === '/') {
|
|
90
|
+
return raw;
|
|
91
|
+
}
|
|
92
|
+
const hasTrailingSlash = raw.endsWith('/');
|
|
93
|
+
const content = hasTrailingSlash ? raw.slice(1, -1) : raw.slice(1);
|
|
94
|
+
const normalized = `/${pathSegments(content, fieldName).join('/')}`;
|
|
95
|
+
return preserveTrailingSlash && hasTrailingSlash ? `${normalized}/` : normalized;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeRelativePath(value, fieldName, allowDirectory = false) {
|
|
99
|
+
const raw = String(value ?? '');
|
|
100
|
+
if (!raw || raw.startsWith('/') || raw.includes('?') || raw.includes('#')) {
|
|
101
|
+
fail(`${fieldName} must be a nonempty relative URL path.`);
|
|
102
|
+
}
|
|
103
|
+
const directory = allowDirectory && raw.endsWith('/');
|
|
104
|
+
const content = directory ? raw.slice(0, -1) : raw;
|
|
105
|
+
const normalized = pathSegments(content, fieldName).join('/');
|
|
106
|
+
return directory ? `${normalized}/` : normalized;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeRoot(value, fieldName) {
|
|
110
|
+
if (value instanceof URL) {
|
|
111
|
+
if (value.protocol !== 'file:') {
|
|
112
|
+
fail(`${fieldName} URL must use the file: protocol.`);
|
|
113
|
+
}
|
|
114
|
+
return path.resolve(fileURLToPath(value));
|
|
115
|
+
}
|
|
116
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
117
|
+
fail(`${fieldName} must be a filesystem path or file URL.`);
|
|
118
|
+
}
|
|
119
|
+
return path.resolve(value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeInclude(value, fieldName) {
|
|
123
|
+
if (value === undefined) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(value)) {
|
|
127
|
+
fail(`${fieldName} must be an array when supplied.`);
|
|
128
|
+
}
|
|
129
|
+
return value.map(function normalizeEntry(entry, index) {
|
|
130
|
+
return normalizeRelativePath(entry, `${fieldName}[${index}]`, true);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeMount(value, index) {
|
|
135
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
136
|
+
fail(`mounts[${index}] must be an object.`);
|
|
137
|
+
}
|
|
138
|
+
if (value.urlPath === undefined) {
|
|
139
|
+
fail(`mounts[${index}].urlPath is required.`);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
urlPath: normalizeUrlPath(value.urlPath, `mounts[${index}].urlPath`),
|
|
143
|
+
root: normalizeRoot(value.root, `mounts[${index}].root`),
|
|
144
|
+
index: value.index === undefined
|
|
145
|
+
? null
|
|
146
|
+
: normalizeRelativePath(value.index, `mounts[${index}].index`),
|
|
147
|
+
include: normalizeInclude(value.include, `mounts[${index}].include`)
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function normalizeMounts(value) {
|
|
152
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
153
|
+
fail('Source server mounts must be a nonempty array.');
|
|
154
|
+
}
|
|
155
|
+
const mounts = value.map(normalizeMount);
|
|
156
|
+
const seen = new Set();
|
|
157
|
+
for (const mount of mounts) {
|
|
158
|
+
if (seen.has(mount.urlPath)) {
|
|
159
|
+
fail(`Source server mount URL path is duplicated: ${mount.urlPath}`);
|
|
160
|
+
}
|
|
161
|
+
seen.add(mount.urlPath);
|
|
162
|
+
}
|
|
163
|
+
return mounts.slice().sort(function longestUrlPathFirst(left, right) {
|
|
164
|
+
return right.urlPath.length - left.urlPath.length;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseRequestPath(rawUrl) {
|
|
169
|
+
let parsed;
|
|
170
|
+
try {
|
|
171
|
+
parsed = new URL(String(rawUrl || '/'), 'http://localhost');
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
let decoded;
|
|
177
|
+
try {
|
|
178
|
+
decoded = decodeURIComponent(parsed.pathname);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
if (!decoded.startsWith('/') || decoded.includes('\\')
|
|
184
|
+
|| /[\x00-\x1f\x7f]/u.test(decoded)) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
if (decoded !== '/' && decoded.split('/').some(function invalidSegment(segment, index) {
|
|
188
|
+
if (index === 0 || (index === decoded.split('/').length - 1 && !segment)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
return !segment || segment === '.' || segment === '..';
|
|
192
|
+
})) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return decoded;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function mountForPath(mounts, requestPath) {
|
|
199
|
+
return mounts.find(function matchesMount(mount) {
|
|
200
|
+
return mount.urlPath === '/'
|
|
201
|
+
|| requestPath === mount.urlPath
|
|
202
|
+
|| requestPath.startsWith(`${mount.urlPath}/`);
|
|
203
|
+
}) || null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function relativePathForRequest(mount, requestPath) {
|
|
207
|
+
const offset = mount.urlPath === '/' ? 1 : mount.urlPath.length;
|
|
208
|
+
let relativePath = requestPath.slice(offset);
|
|
209
|
+
if (relativePath.startsWith('/')) {
|
|
210
|
+
relativePath = relativePath.slice(1);
|
|
211
|
+
}
|
|
212
|
+
if (!relativePath || relativePath.endsWith('/')) {
|
|
213
|
+
if (!mount.index) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
relativePath = `${relativePath}${mount.index}`;
|
|
217
|
+
}
|
|
218
|
+
return relativePath;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function includedByMount(mount, relativePath) {
|
|
222
|
+
if (mount.include === null) {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
return mount.include.some(function matchesInclude(entry) {
|
|
226
|
+
return entry.endsWith('/')
|
|
227
|
+
? relativePath.startsWith(entry)
|
|
228
|
+
: relativePath === entry;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function resolveMountedPath(mount, relativePath) {
|
|
233
|
+
const candidate = path.resolve(mount.root, ...relativePath.split('/'));
|
|
234
|
+
const relative = path.relative(mount.root, candidate);
|
|
235
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`)
|
|
236
|
+
|| path.isAbsolute(relative)) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
return candidate;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function regularFile(filePath) {
|
|
243
|
+
try {
|
|
244
|
+
const information = await stat(filePath);
|
|
245
|
+
return information.isFile() ? information : null;
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function contentType(filePath) {
|
|
256
|
+
return MIME_TYPES.get(path.extname(filePath).toLowerCase())
|
|
257
|
+
|| 'application/octet-stream';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function responseHeaders(settings) {
|
|
261
|
+
const headers = {
|
|
262
|
+
'cache-control': 'no-cache'
|
|
263
|
+
};
|
|
264
|
+
if (settings.crossOriginIsolated) {
|
|
265
|
+
headers['cross-origin-opener-policy'] = 'same-origin';
|
|
266
|
+
headers['cross-origin-embedder-policy'] = 'require-corp';
|
|
267
|
+
}
|
|
268
|
+
return headers;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function sendText(response, method, status, message, headers = {}) {
|
|
272
|
+
response.writeHead(status, {
|
|
273
|
+
...headers,
|
|
274
|
+
'content-type': 'text/plain; charset=utf-8'
|
|
275
|
+
});
|
|
276
|
+
response.end(method === 'HEAD' ? undefined : `${message}\n`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function parseRange(value, size) {
|
|
280
|
+
if (value === undefined) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
if (typeof value !== 'string' || size === 0) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
const match = /^bytes=(\d*)-(\d*)$/u.exec(value.trim());
|
|
287
|
+
if (!match || (!match[1] && !match[2])) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
let start;
|
|
291
|
+
let end;
|
|
292
|
+
if (!match[1]) {
|
|
293
|
+
const suffixLength = Number(match[2]);
|
|
294
|
+
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
start = Math.max(0, size - suffixLength);
|
|
298
|
+
end = size - 1;
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
start = Number(match[1]);
|
|
302
|
+
end = match[2] ? Number(match[2]) : size - 1;
|
|
303
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)
|
|
304
|
+
|| start < 0 || end < start || start >= size) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
end = Math.min(end, size - 1);
|
|
308
|
+
}
|
|
309
|
+
return {start, end};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function streamFile(request, response, filePath, information, settings) {
|
|
313
|
+
const range = parseRange(request.headers.range, information.size);
|
|
314
|
+
if (range === false) {
|
|
315
|
+
sendText(response, request.method, 416, 'Range Not Satisfiable', {
|
|
316
|
+
...responseHeaders(settings),
|
|
317
|
+
'accept-ranges': 'bytes',
|
|
318
|
+
'content-range': `bytes */${information.size}`
|
|
319
|
+
});
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const headers = {
|
|
323
|
+
...responseHeaders(settings),
|
|
324
|
+
'accept-ranges': 'bytes',
|
|
325
|
+
'content-type': contentType(filePath)
|
|
326
|
+
};
|
|
327
|
+
let status = 200;
|
|
328
|
+
let streamOptions;
|
|
329
|
+
if (range) {
|
|
330
|
+
status = 206;
|
|
331
|
+
headers['content-range'] = `bytes ${range.start}-${range.end}/${information.size}`;
|
|
332
|
+
headers['content-length'] = range.end - range.start + 1;
|
|
333
|
+
streamOptions = range;
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
headers['content-length'] = information.size;
|
|
337
|
+
}
|
|
338
|
+
response.writeHead(status, headers);
|
|
339
|
+
if (request.method === 'HEAD') {
|
|
340
|
+
response.end();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
await pipeline(createReadStream(filePath, streamOptions), response);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function publishEvent(onEvent, type, detail) {
|
|
347
|
+
if (typeof onEvent !== 'function') {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
await onEvent({type, at: new Date().toISOString(), ...detail});
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
console.error('[arcane source server] Event listener failed.', error);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function serveRequest(request, response, settings) {
|
|
359
|
+
const method = String(request.method || 'GET').toUpperCase();
|
|
360
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
361
|
+
sendText(response, method, 405, 'Method Not Allowed', {
|
|
362
|
+
...responseHeaders(settings),
|
|
363
|
+
allow: 'GET, HEAD'
|
|
364
|
+
});
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const requestPath = parseRequestPath(request.url);
|
|
368
|
+
if (!requestPath) {
|
|
369
|
+
sendText(response, method, 400, 'Bad Request', responseHeaders(settings));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (requestPath === '/' && settings.startPath !== '/') {
|
|
373
|
+
response.writeHead(302, {
|
|
374
|
+
...responseHeaders(settings),
|
|
375
|
+
location: settings.startPath
|
|
376
|
+
});
|
|
377
|
+
response.end();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const mount = mountForPath(settings.mounts, requestPath);
|
|
381
|
+
const relativePath = mount ? relativePathForRequest(mount, requestPath) : null;
|
|
382
|
+
if (!mount || !relativePath || !includedByMount(mount, relativePath)) {
|
|
383
|
+
sendText(response, method, 404, 'Not Found', responseHeaders(settings));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const filePath = resolveMountedPath(mount, relativePath);
|
|
387
|
+
const information = filePath ? await regularFile(filePath) : null;
|
|
388
|
+
if (!information) {
|
|
389
|
+
sendText(response, method, 404, 'Not Found', responseHeaders(settings));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
await streamFile(request, response, filePath, information, settings);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function createRequestListener(settings) {
|
|
396
|
+
return async function sourceServerRequest(request, response) {
|
|
397
|
+
try {
|
|
398
|
+
await serveRequest(request, response, settings);
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
console.error('[arcane source server] Request failed.', error);
|
|
402
|
+
await publishEvent(settings.onEvent, 'source-server.request.failed', {
|
|
403
|
+
method: request.method,
|
|
404
|
+
url: request.url,
|
|
405
|
+
error
|
|
406
|
+
});
|
|
407
|
+
if (response.headersSent) {
|
|
408
|
+
response.destroy(error);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
sendText(response, request.method, 500, 'Internal Server Error',
|
|
412
|
+
responseHeaders(settings));
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function createServer(settings) {
|
|
418
|
+
const listener = createRequestListener(settings);
|
|
419
|
+
if (settings.tls === undefined || settings.tls === null || settings.tls === false) {
|
|
420
|
+
return http.createServer(listener);
|
|
421
|
+
}
|
|
422
|
+
if (typeof settings.tls !== 'object' || Array.isArray(settings.tls)) {
|
|
423
|
+
fail('Source server tls must be a Node HTTPS options object when supplied.');
|
|
424
|
+
}
|
|
425
|
+
return https.createServer(settings.tls, listener);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function listen(server, host, port, onOperationalError) {
|
|
429
|
+
return new Promise(function settleListen(resolve, reject) {
|
|
430
|
+
let listening = false;
|
|
431
|
+
function cleanupBeforeListening() {
|
|
432
|
+
server.off('error', onError);
|
|
433
|
+
server.off('listening', onListening);
|
|
434
|
+
}
|
|
435
|
+
function onError(error) {
|
|
436
|
+
if (listening) {
|
|
437
|
+
onOperationalError(error);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
cleanupBeforeListening();
|
|
441
|
+
reject(error);
|
|
442
|
+
}
|
|
443
|
+
function onListening() {
|
|
444
|
+
listening = true;
|
|
445
|
+
server.off('listening', onListening);
|
|
446
|
+
resolve(onError);
|
|
447
|
+
}
|
|
448
|
+
server.on('error', onError);
|
|
449
|
+
server.once('listening', onListening);
|
|
450
|
+
server.listen(port, host);
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function urlHostname(host) {
|
|
455
|
+
const publicHost = host === '0.0.0.0' || host === '::' ? 'localhost' : host;
|
|
456
|
+
return publicHost.includes(':') ? `[${publicHost}]` : publicHost;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function closeFunction(server) {
|
|
460
|
+
let activeClose = null;
|
|
461
|
+
return function close() {
|
|
462
|
+
if (activeClose) {
|
|
463
|
+
return activeClose;
|
|
464
|
+
}
|
|
465
|
+
if (!server.listening) {
|
|
466
|
+
return Promise.resolve();
|
|
467
|
+
}
|
|
468
|
+
activeClose = new Promise(function settleClose(resolve, reject) {
|
|
469
|
+
server.close(function onClosed(error) {
|
|
470
|
+
if (error) {
|
|
471
|
+
activeClose = null;
|
|
472
|
+
reject(error);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
resolve();
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
return activeClose;
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export async function startSourceExampleServer(options = {}) {
|
|
483
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
484
|
+
fail('Source server options must be an object.');
|
|
485
|
+
}
|
|
486
|
+
if (options.signal && (typeof options.signal.addEventListener !== 'function'
|
|
487
|
+
|| typeof options.signal.removeEventListener !== 'function')) {
|
|
488
|
+
fail('Source server signal must be an AbortSignal when supplied.');
|
|
489
|
+
}
|
|
490
|
+
throwIfAborted(options.signal);
|
|
491
|
+
const settings = {
|
|
492
|
+
mounts: normalizeMounts(options.mounts),
|
|
493
|
+
startPath: normalizeUrlPath(options.startPath ?? '/', 'startPath', true),
|
|
494
|
+
host: normalizeHost(options.host),
|
|
495
|
+
port: normalizePort(options.port),
|
|
496
|
+
crossOriginIsolated: options.crossOriginIsolated === true,
|
|
497
|
+
tls: options.tls,
|
|
498
|
+
onEvent: options.onEvent
|
|
499
|
+
};
|
|
500
|
+
const server = createServer(settings);
|
|
501
|
+
const close = closeFunction(server);
|
|
502
|
+
let operationalError = null;
|
|
503
|
+
let serverErrorListener = null;
|
|
504
|
+
let abortListener = null;
|
|
505
|
+
let settleClosed;
|
|
506
|
+
let rejectClosed;
|
|
507
|
+
const closed = new Promise(function waitForClose(resolve, reject) {
|
|
508
|
+
settleClosed = resolve;
|
|
509
|
+
rejectClosed = reject;
|
|
510
|
+
});
|
|
511
|
+
closed.catch(function retainObservableLifecycle() {
|
|
512
|
+
return undefined;
|
|
513
|
+
});
|
|
514
|
+
server.once('close', function observeClose() {
|
|
515
|
+
if (serverErrorListener) {
|
|
516
|
+
server.off('error', serverErrorListener);
|
|
517
|
+
}
|
|
518
|
+
if (abortListener) {
|
|
519
|
+
options.signal.removeEventListener('abort', abortListener);
|
|
520
|
+
}
|
|
521
|
+
if (operationalError) {
|
|
522
|
+
rejectClosed(operationalError);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
settleClosed();
|
|
526
|
+
});
|
|
527
|
+
function reportOperationalError(error) {
|
|
528
|
+
operationalError = operationalError || error;
|
|
529
|
+
console.error('[arcane source server] Server failed.', error);
|
|
530
|
+
publishEvent(settings.onEvent, 'source-server.failed', {error})
|
|
531
|
+
.catch(function reportEventFailure(eventError) {
|
|
532
|
+
console.error('[arcane source server] Failure event could not be reported.',
|
|
533
|
+
eventError);
|
|
534
|
+
});
|
|
535
|
+
close().catch(function reportOperationalCloseFailure(closeError) {
|
|
536
|
+
console.error('[arcane source server] Failure close failed.', closeError);
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
await publishEvent(settings.onEvent, 'source-server.starting', {
|
|
540
|
+
host: settings.host,
|
|
541
|
+
port: settings.port
|
|
542
|
+
});
|
|
543
|
+
throwIfAborted(options.signal);
|
|
544
|
+
serverErrorListener = await listen(server, settings.host, settings.port,
|
|
545
|
+
reportOperationalError);
|
|
546
|
+
const address = server.address();
|
|
547
|
+
if (!address || typeof address === 'string') {
|
|
548
|
+
await new Promise(function closeUnknownAddress(resolve) {
|
|
549
|
+
server.close(resolve);
|
|
550
|
+
});
|
|
551
|
+
fail('Source server did not expose a TCP address.', 'ARCANE_SOURCE_SERVER_ADDRESS_UNAVAILABLE');
|
|
552
|
+
}
|
|
553
|
+
const protocol = settings.tls ? 'https:' : 'http:';
|
|
554
|
+
const origin = `${protocol}//${urlHostname(settings.host)}:${address.port}`;
|
|
555
|
+
const url = `${origin}${settings.startPath}`;
|
|
556
|
+
if (options.signal) {
|
|
557
|
+
abortListener = function closeAfterAbort() {
|
|
558
|
+
close().catch(function reportCloseFailure(error) {
|
|
559
|
+
console.error('[arcane source server] Abort close failed.', error);
|
|
560
|
+
});
|
|
561
|
+
};
|
|
562
|
+
options.signal.addEventListener('abort', abortListener, {once: true});
|
|
563
|
+
if (options.signal.aborted) {
|
|
564
|
+
await close();
|
|
565
|
+
throwIfAborted(options.signal);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
await publishEvent(settings.onEvent, 'source-server.started', {
|
|
569
|
+
host: settings.host,
|
|
570
|
+
port: address.port,
|
|
571
|
+
origin,
|
|
572
|
+
url
|
|
573
|
+
});
|
|
574
|
+
if (options.signal?.aborted) {
|
|
575
|
+
await close();
|
|
576
|
+
throwIfAborted(options.signal);
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
server,
|
|
580
|
+
protocol,
|
|
581
|
+
host: settings.host,
|
|
582
|
+
port: address.port,
|
|
583
|
+
origin,
|
|
584
|
+
url,
|
|
585
|
+
close,
|
|
586
|
+
closed
|
|
587
|
+
};
|
|
588
|
+
}
|