@deneb-ui/cli 2.0.17 → 2.0.19
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.
|
@@ -1,16 +1,688 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const http = require('node:http');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { spawn } = require('node:child_process');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_API_PORT = 4174;
|
|
10
|
+
const DEFAULT_PREVIEW_PORT = 4173;
|
|
11
|
+
const MAX_LOG_LENGTH = 96 * 1024;
|
|
12
|
+
const LOOPBACK_HOST = '127.0.0.1';
|
|
13
|
+
const LOCAL_PREVIEW_ROUTE = '/__fivora_local_preview';
|
|
14
|
+
const FALLBACK_LOCAL_VISUAL_BRIDGE_SCRIPT = String.raw`
|
|
15
|
+
(() => {
|
|
16
|
+
const PREVIOUS_BRAND = ['market', 'place'].join('');
|
|
17
|
+
const PREVIOUS_BRIDGE_KEY = '__' + PREVIOUS_BRAND.toUpperCase() + '_LOCAL_VISUAL_BRIDGE__';
|
|
18
|
+
const PREVIOUS_PREVIEW_PREFIX = PREVIOUS_BRAND.toUpperCase() + '_PREVIEW_';
|
|
19
|
+
const previousPreviewMessage = (suffix) => PREVIOUS_PREVIEW_PREFIX + suffix;
|
|
20
|
+
if (window.__FIVORA_LOCAL_VISUAL_BRIDGE__ || window[PREVIOUS_BRIDGE_KEY]) return;
|
|
21
|
+
window.__FIVORA_LOCAL_VISUAL_BRIDGE__ = true;
|
|
22
|
+
window[PREVIOUS_BRIDGE_KEY] = true;
|
|
23
|
+
|
|
24
|
+
const EDIT_MODE_MESSAGE = 'FIVORA_PREVIEW_EDIT_MODE';
|
|
25
|
+
const LEGACY_EDIT_MODE_MESSAGE = previousPreviewMessage('EDIT_MODE');
|
|
26
|
+
const CLICK_MESSAGE = 'FIVORA_PREVIEW_ELEMENT_CLICKED';
|
|
27
|
+
const LEGACY_CLICK_MESSAGE = previousPreviewMessage('ELEMENT_CLICKED');
|
|
28
|
+
const READY_MESSAGE = 'FIVORA_PREVIEW_READY';
|
|
29
|
+
const LEGACY_READY_MESSAGE = previousPreviewMessage('READY');
|
|
30
|
+
const FIELD_ATTRIBUTE = 'data-preview-field-path';
|
|
31
|
+
const LIST_ATTRIBUTE = 'data-preview-list-path';
|
|
32
|
+
const ITEM_ATTRIBUTE = 'data-preview-item-path';
|
|
33
|
+
const ACTIVE_ATTRIBUTE = 'data-fivora-local-edit-target';
|
|
34
|
+
const SELECTED_ATTRIBUTE = 'data-fivora-local-selected-target';
|
|
35
|
+
const LEGACY_ACTIVE_ATTRIBUTE = 'data-' + PREVIOUS_BRAND + '-local-edit-target';
|
|
36
|
+
const LEGACY_SELECTED_ATTRIBUTE = 'data-' + PREVIOUS_BRAND + '-local-selected-target';
|
|
37
|
+
const EDITABLE_SELECTOR =
|
|
38
|
+
'[data-preview-field-path], [data-preview-list-path], [data-preview-item-path]';
|
|
39
|
+
// The local lab is edit-first when embedded. The portal can still switch the
|
|
40
|
+
// preview back to normal navigation mode with EDIT_MODE_MESSAGE.
|
|
41
|
+
let editMode = window.parent !== window;
|
|
42
|
+
let descriptors = new Map();
|
|
43
|
+
let activeTarget = null;
|
|
44
|
+
let selectedTarget = null;
|
|
45
|
+
let parentOrigin = '*';
|
|
46
|
+
|
|
47
|
+
const style = document.createElement('style');
|
|
48
|
+
style.setAttribute('data-fivora-local-visual-bridge', '');
|
|
49
|
+
style.setAttribute('data-' + PREVIOUS_BRAND + '-local-visual-bridge', '');
|
|
50
|
+
style.textContent = [
|
|
51
|
+
'[' + ACTIVE_ATTRIBUTE + '], [' + LEGACY_ACTIVE_ATTRIBUTE + '] {',
|
|
52
|
+
' outline: 2px solid #06b6d4 !important;',
|
|
53
|
+
' outline-offset: 3px !important;',
|
|
54
|
+
' cursor: pointer !important;',
|
|
55
|
+
' box-shadow: 0 0 0 5px rgba(6, 182, 212, 0.16) !important;',
|
|
56
|
+
'}',
|
|
57
|
+
'[' + SELECTED_ATTRIBUTE + '], [' + LEGACY_SELECTED_ATTRIBUTE + '] {',
|
|
58
|
+
' outline: 2px dashed #06b6d4 !important;',
|
|
59
|
+
' outline-offset: 3px !important;',
|
|
60
|
+
' box-shadow: 0 0 0 5px rgba(6, 182, 212, 0.18) !important;',
|
|
61
|
+
'}',
|
|
62
|
+
'html[data-fivora-local-edit-mode="true"] [data-preview-field-path],',
|
|
63
|
+
'html[data-fivora-local-edit-mode="true"] [data-preview-list-path],',
|
|
64
|
+
'html[data-fivora-local-edit-mode="true"] [data-preview-item-path],',
|
|
65
|
+
'html[data-fivora-local-edit-mode="true"] [data-preview-field-path],',
|
|
66
|
+
'html[data-fivora-local-edit-mode="true"] [data-preview-list-path],',
|
|
67
|
+
'html[data-fivora-local-edit-mode="true"] [data-preview-item-path] {',
|
|
68
|
+
' cursor: pointer !important;',
|
|
69
|
+
'}',
|
|
70
|
+
].join('\n');
|
|
71
|
+
document.head.appendChild(style);
|
|
72
|
+
document.documentElement.setAttribute(
|
|
73
|
+
'data-fivora-local-edit-mode',
|
|
74
|
+
String(editMode),
|
|
75
|
+
);
|
|
76
|
+
document.documentElement.setAttribute(
|
|
77
|
+
'data-fivora-local-edit-mode',
|
|
78
|
+
String(editMode),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
function rememberParentOrigin(event) {
|
|
82
|
+
if (event.source !== window.parent) return false;
|
|
83
|
+
if (parentOrigin === '*' && event.origin && event.origin !== 'null') {
|
|
84
|
+
parentOrigin = event.origin;
|
|
85
|
+
}
|
|
86
|
+
return parentOrigin === '*' || event.origin === parentOrigin;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function post(payload) {
|
|
90
|
+
window.parent.postMessage(payload, parentOrigin);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function clearActiveTarget() {
|
|
94
|
+
if (activeTarget) {
|
|
95
|
+
activeTarget.removeAttribute(ACTIVE_ATTRIBUTE);
|
|
96
|
+
activeTarget.removeAttribute(LEGACY_ACTIVE_ATTRIBUTE);
|
|
97
|
+
}
|
|
98
|
+
activeTarget = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function selectTarget(target) {
|
|
102
|
+
if (selectedTarget && selectedTarget !== target) {
|
|
103
|
+
selectedTarget.removeAttribute(SELECTED_ATTRIBUTE);
|
|
104
|
+
selectedTarget.removeAttribute(LEGACY_SELECTED_ATTRIBUTE);
|
|
105
|
+
}
|
|
106
|
+
selectedTarget = target;
|
|
107
|
+
selectedTarget.setAttribute(SELECTED_ATTRIBUTE, 'true');
|
|
108
|
+
selectedTarget.setAttribute(LEGACY_SELECTED_ATTRIBUTE, 'true');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function clearSelectedTarget() {
|
|
112
|
+
if (selectedTarget) {
|
|
113
|
+
selectedTarget.removeAttribute(SELECTED_ATTRIBUTE);
|
|
114
|
+
selectedTarget.removeAttribute(LEGACY_SELECTED_ATTRIBUTE);
|
|
115
|
+
}
|
|
116
|
+
selectedTarget = null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function findEditableTarget(rawTarget) {
|
|
120
|
+
if (!(rawTarget instanceof Element)) return null;
|
|
121
|
+
return rawTarget.closest(EDITABLE_SELECTOR);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function fieldValue(target, fieldPath) {
|
|
125
|
+
const descriptor = descriptors.get(fieldPath);
|
|
126
|
+
if (descriptor && ['string', 'number', 'boolean'].includes(typeof descriptor.value)) {
|
|
127
|
+
return descriptor.value;
|
|
128
|
+
}
|
|
129
|
+
if (target instanceof HTMLImageElement) return target.currentSrc || target.src || '';
|
|
130
|
+
return (target.textContent || '').trim();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function onPointerOver(event) {
|
|
134
|
+
if (!editMode) return;
|
|
135
|
+
const target = findEditableTarget(event.target);
|
|
136
|
+
if (!target || target === activeTarget) return;
|
|
137
|
+
clearActiveTarget();
|
|
138
|
+
activeTarget = target;
|
|
139
|
+
activeTarget.setAttribute(ACTIVE_ATTRIBUTE, 'true');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function onPointerOut(event) {
|
|
143
|
+
if (!editMode || !activeTarget) return;
|
|
144
|
+
const next = event.relatedTarget;
|
|
145
|
+
if (next instanceof Node && activeTarget.contains(next)) return;
|
|
146
|
+
clearActiveTarget();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function findRelatedFields(target, fieldPath, itemPath) {
|
|
150
|
+
const related = [];
|
|
151
|
+
const seen = new Set(fieldPath ? [fieldPath] : []);
|
|
152
|
+
const prefix =
|
|
153
|
+
itemPath ||
|
|
154
|
+
(fieldPath && fieldPath.replace(/(\[\d+\])?\.\w+$/, '').replace(/\[\d+\]$/, ''));
|
|
155
|
+
if (prefix) {
|
|
156
|
+
for (const [path, descriptor] of descriptors) {
|
|
157
|
+
if (
|
|
158
|
+
path !== fieldPath &&
|
|
159
|
+
!seen.has(path) &&
|
|
160
|
+
(path.startsWith(prefix + '.') || path.startsWith(prefix + '[')) &&
|
|
161
|
+
descriptor?.kind !== 'collection'
|
|
162
|
+
) {
|
|
163
|
+
seen.add(path);
|
|
164
|
+
related.push({
|
|
165
|
+
path,
|
|
166
|
+
label: descriptor.label || path.split('.').pop() || 'Content',
|
|
167
|
+
type: descriptor.type || 'text',
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
let current = target instanceof Element ? target : null;
|
|
173
|
+
let depth = 0;
|
|
174
|
+
while (current && current !== document.body && depth < 3) {
|
|
175
|
+
const candidatePath = current.getAttribute(FIELD_ATTRIBUTE);
|
|
176
|
+
if (
|
|
177
|
+
candidatePath &&
|
|
178
|
+
candidatePath !== fieldPath &&
|
|
179
|
+
!seen.has(candidatePath)
|
|
180
|
+
) {
|
|
181
|
+
const descriptor = descriptors.get(candidatePath);
|
|
182
|
+
if (descriptor?.kind !== 'collection') {
|
|
183
|
+
seen.add(candidatePath);
|
|
184
|
+
related.push({
|
|
185
|
+
path: candidatePath,
|
|
186
|
+
label: descriptor?.label || candidatePath.split('.').pop() || 'Content',
|
|
187
|
+
type: descriptor?.type || 'text',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
current = current.parentElement;
|
|
192
|
+
depth += 1;
|
|
193
|
+
}
|
|
194
|
+
return related;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function onClick(event) {
|
|
198
|
+
if (!editMode) return;
|
|
199
|
+
const target = findEditableTarget(event.target);
|
|
200
|
+
if (!target) return;
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
event.stopPropagation();
|
|
203
|
+
event.stopImmediatePropagation();
|
|
204
|
+
selectTarget(target);
|
|
205
|
+
|
|
206
|
+
const fieldTarget = target.closest('[' + FIELD_ATTRIBUTE + ']');
|
|
207
|
+
const listTarget = target.closest('[' + LIST_ATTRIBUTE + ']');
|
|
208
|
+
const itemTarget = target.closest('[' + ITEM_ATTRIBUTE + ']');
|
|
209
|
+
const fieldPath = fieldTarget?.getAttribute(FIELD_ATTRIBUTE) || null;
|
|
210
|
+
const listPath = listTarget?.getAttribute(LIST_ATTRIBUTE) || null;
|
|
211
|
+
const itemPath = itemTarget?.getAttribute(ITEM_ATTRIBUTE) || null;
|
|
212
|
+
const descriptor = fieldPath ? descriptors.get(fieldPath) : null;
|
|
213
|
+
const rect = target.getBoundingClientRect();
|
|
214
|
+
const itemIndexMatch = itemPath?.match(/\[(\d+)\](?!.*\[\d+\])/);
|
|
215
|
+
|
|
216
|
+
const clickPayload = {
|
|
217
|
+
type: CLICK_MESSAGE,
|
|
218
|
+
fieldPath,
|
|
219
|
+
fieldValue: fieldPath ? fieldValue(fieldTarget || target, fieldPath) : '',
|
|
220
|
+
elementTag: target.tagName.toLowerCase(),
|
|
221
|
+
isImage:
|
|
222
|
+
target instanceof HTMLImageElement || descriptor?.type === 'image',
|
|
223
|
+
boundingRect: {
|
|
224
|
+
top: rect.top,
|
|
225
|
+
left: rect.left,
|
|
226
|
+
width: rect.width,
|
|
227
|
+
height: rect.height,
|
|
228
|
+
},
|
|
229
|
+
collectionPath: listPath,
|
|
230
|
+
listPath,
|
|
231
|
+
itemPath,
|
|
232
|
+
itemIndex: itemIndexMatch ? Number(itemIndexMatch[1]) : null,
|
|
233
|
+
descriptorKind: fieldPath ? 'field' : listPath ? 'collection' : null,
|
|
234
|
+
relatedFields: findRelatedFields(target, fieldPath, itemPath),
|
|
235
|
+
};
|
|
236
|
+
post(clickPayload);
|
|
237
|
+
post({ ...clickPayload, type: LEGACY_CLICK_MESSAGE });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
window.addEventListener('message', (event) => {
|
|
241
|
+
if (!rememberParentOrigin(event) || !event.data || typeof event.data !== 'object') {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (event.data.type !== EDIT_MODE_MESSAGE && event.data.type !== LEGACY_EDIT_MODE_MESSAGE) return;
|
|
245
|
+
editMode = event.data.editMode === true;
|
|
246
|
+
descriptors = new Map(
|
|
247
|
+
Array.isArray(event.data.fields)
|
|
248
|
+
? event.data.fields
|
|
249
|
+
.filter((field) => field && typeof field.path === 'string')
|
|
250
|
+
.map((field) => [field.path, field])
|
|
251
|
+
: [],
|
|
252
|
+
);
|
|
253
|
+
document.documentElement.setAttribute(
|
|
254
|
+
'data-fivora-local-edit-mode',
|
|
255
|
+
String(editMode),
|
|
256
|
+
);
|
|
257
|
+
document.documentElement.setAttribute(
|
|
258
|
+
'data-fivora-local-edit-mode',
|
|
259
|
+
String(editMode),
|
|
260
|
+
);
|
|
261
|
+
if (!editMode) {
|
|
262
|
+
clearActiveTarget();
|
|
263
|
+
clearSelectedTarget();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
document.addEventListener('mouseover', onPointerOver, true);
|
|
268
|
+
document.addEventListener('mouseout', onPointerOut, true);
|
|
269
|
+
document.addEventListener('click', onClick, true);
|
|
270
|
+
post({ type: READY_MESSAGE, pathname: window.location.pathname });
|
|
271
|
+
post({ type: LEGACY_READY_MESSAGE, pathname: window.location.pathname });
|
|
272
|
+
})();
|
|
273
|
+
`;
|
|
274
|
+
|
|
275
|
+
let LOCAL_VISUAL_BRIDGE_SCRIPT = FALLBACK_LOCAL_VISUAL_BRIDGE_SCRIPT;
|
|
276
|
+
try {
|
|
277
|
+
const productionBridge = require('./template-preview-focus-bridge.cjs');
|
|
278
|
+
if (typeof productionBridge === 'string' && productionBridge.trim()) {
|
|
279
|
+
LOCAL_VISUAL_BRIDGE_SCRIPT = productionBridge;
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
// Source checkouts can run before the generated production bridge exists.
|
|
283
|
+
// The package build always creates it; retain a compact fallback for safety.
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function fail(message) {
|
|
287
|
+
process.stderr.write(`Local Template Lab: ${message}\n`);
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
// Validate the generated iframe program as well as this outer runner file.
|
|
293
|
+
new Function(LOCAL_VISUAL_BRIDGE_SCRIPT);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
fail(`Visual editor bridge is invalid: ${error.message}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function parsePort(value, flag, fallback) {
|
|
299
|
+
if (value === undefined) return fallback;
|
|
300
|
+
const port = Number(value);
|
|
301
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
|
|
302
|
+
fail(`${flag} must be a port between 1024 and 65535.`);
|
|
303
|
+
}
|
|
304
|
+
return port;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function parseArguments(argv) {
|
|
308
|
+
const args = [...argv];
|
|
309
|
+
let templatePath = '';
|
|
310
|
+
let apiPort;
|
|
311
|
+
let previewPort;
|
|
312
|
+
let skipInstall = false;
|
|
313
|
+
|
|
314
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
315
|
+
const arg = args[index];
|
|
316
|
+
if (arg === '--api-port') {
|
|
317
|
+
apiPort = args[index + 1];
|
|
318
|
+
index += 1;
|
|
319
|
+
} else if (arg.startsWith('--api-port=')) {
|
|
320
|
+
apiPort = arg.slice('--api-port='.length);
|
|
321
|
+
} else if (arg === '--preview-port') {
|
|
322
|
+
previewPort = args[index + 1];
|
|
323
|
+
index += 1;
|
|
324
|
+
} else if (arg.startsWith('--preview-port=')) {
|
|
325
|
+
previewPort = arg.slice('--preview-port='.length);
|
|
326
|
+
} else if (arg === '--skip-install') {
|
|
327
|
+
skipInstall = true;
|
|
328
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
329
|
+
process.stdout.write(
|
|
330
|
+
[
|
|
331
|
+
'Fivora Local Template Lab',
|
|
332
|
+
'',
|
|
333
|
+
'Usage:',
|
|
334
|
+
' npm run lab -- <template-directory> [options]',
|
|
335
|
+
'',
|
|
336
|
+
'Options:',
|
|
337
|
+
' --api-port <port> Loopback controller port (default: 4174)',
|
|
338
|
+
' --preview-port <port> Template dev-server port (default: 4173)',
|
|
339
|
+
' --skip-install Do not install missing local dependencies',
|
|
340
|
+
'',
|
|
341
|
+
].join('\n'),
|
|
342
|
+
);
|
|
343
|
+
process.exit(0);
|
|
344
|
+
} else if (arg.startsWith('-')) {
|
|
345
|
+
fail(`Unknown option: ${arg}`);
|
|
346
|
+
} else if (!templatePath) {
|
|
347
|
+
templatePath = arg;
|
|
348
|
+
} else {
|
|
349
|
+
fail(`Unexpected argument: ${arg}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (!templatePath) {
|
|
354
|
+
fail('A template directory is required. Run with --help for usage.');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
templatePath: path.resolve(templatePath),
|
|
359
|
+
apiPort: parsePort(apiPort, '--api-port', DEFAULT_API_PORT),
|
|
360
|
+
previewPort: parsePort(
|
|
361
|
+
previewPort,
|
|
362
|
+
'--preview-port',
|
|
363
|
+
DEFAULT_PREVIEW_PORT,
|
|
364
|
+
),
|
|
365
|
+
skipInstall,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function readJson(filePath, label) {
|
|
370
|
+
try {
|
|
371
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
372
|
+
} catch (error) {
|
|
373
|
+
fail(
|
|
374
|
+
`${label} is missing or invalid at ${filePath}: ${
|
|
375
|
+
error instanceof Error ? error.message : 'unknown error'
|
|
376
|
+
}`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const options = parseArguments(process.argv.slice(2));
|
|
382
|
+
if (!fs.existsSync(options.templatePath)) {
|
|
383
|
+
fail(`Template directory does not exist: ${options.templatePath}`);
|
|
384
|
+
}
|
|
385
|
+
if (!fs.statSync(options.templatePath).isDirectory()) {
|
|
386
|
+
fail(`Template path must be a directory: ${options.templatePath}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const manifestFile = [
|
|
390
|
+
'fivora-template.json',
|
|
391
|
+
'fivora-template.json',
|
|
392
|
+
].find((name) => fs.existsSync(path.join(options.templatePath, name))) || 'fivora-template.json';
|
|
393
|
+
|
|
394
|
+
const manifestPath = path.join(options.templatePath, manifestFile);
|
|
395
|
+
const packagePath = path.join(options.templatePath, 'package.json');
|
|
396
|
+
const manifest = readJson(manifestPath, 'Template manifest');
|
|
397
|
+
const packageJson = readJson(packagePath, 'package.json');
|
|
398
|
+
|
|
399
|
+
if (manifest.framework !== 'nextjs-static-export') {
|
|
400
|
+
fail('Template manifest framework must be "nextjs-static-export".');
|
|
401
|
+
}
|
|
402
|
+
if (!packageJson.scripts || typeof packageJson.scripts.dev !== 'string') {
|
|
403
|
+
fail('Template package.json must define a dev script for live preview.');
|
|
404
|
+
}
|
|
405
|
+
if (typeof manifest.siteDataFile !== 'string' || !manifest.siteDataFile.trim()) {
|
|
406
|
+
fail('Template manifest siteDataFile is required.');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const siteDataPath = path.resolve(
|
|
410
|
+
options.templatePath,
|
|
411
|
+
manifest.siteDataFile.trim(),
|
|
412
|
+
);
|
|
413
|
+
const relativeSiteDataPath = path.relative(options.templatePath, siteDataPath);
|
|
414
|
+
if (
|
|
415
|
+
relativeSiteDataPath.startsWith('..') ||
|
|
416
|
+
path.isAbsolute(relativeSiteDataPath)
|
|
417
|
+
) {
|
|
418
|
+
fail('Template manifest siteDataFile must stay inside the template directory.');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const controllerToken = crypto.randomBytes(24).toString('base64url');
|
|
422
|
+
const apiUrl = `http://${LOOPBACK_HOST}:${options.apiPort}`;
|
|
423
|
+
const sourcePreviewUrl = `http://${LOOPBACK_HOST}:${options.previewPort}`;
|
|
424
|
+
const previewUrl = `${apiUrl}${LOCAL_PREVIEW_ROUTE}`;
|
|
425
|
+
const validatorPath = fs.existsSync(
|
|
426
|
+
path.join(__dirname, 'deneb-template-validator.cjs'),
|
|
427
|
+
)
|
|
428
|
+
? path.join(__dirname, 'deneb-template-validator.cjs')
|
|
429
|
+
: path.join(__dirname, 'fivora-template-validator.cjs');
|
|
430
|
+
|
|
431
|
+
let shuttingDown = false;
|
|
432
|
+
let devProcess = null;
|
|
433
|
+
let validationProcess = null;
|
|
434
|
+
let readinessTimer = null;
|
|
435
|
+
let devRestartTimer = null;
|
|
436
|
+
let devRestartAttempts = 0;
|
|
437
|
+
const MAX_DEV_RESTART_ATTEMPTS = 5;
|
|
438
|
+
let devLog = '';
|
|
439
|
+
let validationLog = '';
|
|
440
|
+
|
|
441
|
+
const state = {
|
|
442
|
+
protocolVersion: 2,
|
|
443
|
+
connected: true,
|
|
444
|
+
templateName:
|
|
445
|
+
typeof manifest.name === 'string' && manifest.name.trim()
|
|
446
|
+
? manifest.name.trim()
|
|
447
|
+
: typeof packageJson.name === 'string'
|
|
448
|
+
? packageJson.name
|
|
449
|
+
: path.basename(options.templatePath),
|
|
450
|
+
templatePath: options.templatePath,
|
|
451
|
+
previewUrl,
|
|
452
|
+
apiUrl,
|
|
453
|
+
devStatus: 'starting',
|
|
454
|
+
devError: null,
|
|
455
|
+
startedAt: new Date().toISOString(),
|
|
456
|
+
validation: {
|
|
457
|
+
status: 'idle',
|
|
458
|
+
startedAt: null,
|
|
459
|
+
completedAt: null,
|
|
460
|
+
exitCode: null,
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
function appendLog(target, chunk) {
|
|
465
|
+
const text = chunk.toString();
|
|
466
|
+
if (target === 'dev') {
|
|
467
|
+
devLog = `${devLog}${text}`.slice(-MAX_LOG_LENGTH);
|
|
468
|
+
} else {
|
|
469
|
+
validationLog = `${validationLog}${text}`.slice(-MAX_LOG_LENGTH);
|
|
470
|
+
}
|
|
471
|
+
process.stdout.write(text);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function statusPayload() {
|
|
475
|
+
return {
|
|
476
|
+
...state,
|
|
477
|
+
devLog,
|
|
478
|
+
validationLog,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function setCorsHeaders(request, response) {
|
|
483
|
+
const origin = request.headers.origin;
|
|
484
|
+
if (origin && /^(https?:\/\/|null$)/.test(origin)) {
|
|
485
|
+
response.setHeader('Access-Control-Allow-Origin', origin);
|
|
486
|
+
response.setHeader('Vary', 'Origin');
|
|
487
|
+
}
|
|
488
|
+
response.setHeader(
|
|
489
|
+
'Access-Control-Allow-Headers',
|
|
490
|
+
'Authorization, Content-Type',
|
|
491
|
+
);
|
|
492
|
+
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
493
|
+
response.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
494
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
495
|
+
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function sendJson(response, statusCode, payload) {
|
|
499
|
+
const body = JSON.stringify(payload);
|
|
500
|
+
response.statusCode = statusCode;
|
|
501
|
+
response.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
502
|
+
response.setHeader('Content-Length', Buffer.byteLength(body));
|
|
503
|
+
response.end(body);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function isAuthorized(request) {
|
|
507
|
+
return request.headers.authorization === `Bearer ${controllerToken}`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function readSiteData() {
|
|
511
|
+
try {
|
|
512
|
+
return JSON.parse(fs.readFileSync(siteDataPath, 'utf8'));
|
|
513
|
+
} catch (error) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
`Unable to read ${manifest.siteDataFile}: ${
|
|
516
|
+
error instanceof Error ? error.message : 'unknown error'
|
|
517
|
+
}`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function startValidation() {
|
|
523
|
+
if (validationProcess) return false;
|
|
524
|
+
|
|
525
|
+
validationLog = '';
|
|
526
|
+
state.validation = {
|
|
527
|
+
status: 'running',
|
|
528
|
+
startedAt: new Date().toISOString(),
|
|
529
|
+
completedAt: null,
|
|
530
|
+
exitCode: null,
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
validationProcess = spawn(
|
|
534
|
+
process.execPath,
|
|
535
|
+
[validatorPath, 'validate', options.templatePath],
|
|
536
|
+
{
|
|
537
|
+
cwd: __dirname,
|
|
538
|
+
env: process.env,
|
|
539
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
validationProcess.stdout.on('data', (chunk) => appendLog('validation', chunk));
|
|
543
|
+
validationProcess.stderr.on('data', (chunk) => appendLog('validation', chunk));
|
|
544
|
+
validationProcess.on('error', (error) => {
|
|
545
|
+
appendLog('validation', `\nUnable to start validation: ${error.message}\n`);
|
|
546
|
+
});
|
|
547
|
+
validationProcess.on('close', (code) => {
|
|
548
|
+
state.validation = {
|
|
549
|
+
...state.validation,
|
|
550
|
+
status: code === 0 ? 'passed' : 'failed',
|
|
551
|
+
completedAt: new Date().toISOString(),
|
|
552
|
+
exitCode: code,
|
|
553
|
+
};
|
|
554
|
+
validationProcess = null;
|
|
555
|
+
});
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function probePreview() {
|
|
560
|
+
if (shuttingDown || state.devStatus === 'ready') return;
|
|
561
|
+
const request = http.get(sourcePreviewUrl, (response) => {
|
|
562
|
+
response.resume();
|
|
563
|
+
if (response.statusCode && response.statusCode < 500) {
|
|
564
|
+
state.devStatus = 'ready';
|
|
565
|
+
state.devError = null;
|
|
566
|
+
devRestartAttempts = 0;
|
|
567
|
+
process.stdout.write(`\nLive preview ready: ${previewUrl}\n`);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
readinessTimer = setTimeout(probePreview, 600);
|
|
571
|
+
});
|
|
572
|
+
request.setTimeout(900, () => request.destroy());
|
|
573
|
+
request.on('error', () => {
|
|
574
|
+
readinessTimer = setTimeout(probePreview, 600);
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function scheduleDevRestart(reason) {
|
|
579
|
+
if (shuttingDown || devRestartTimer) return;
|
|
580
|
+
if (devRestartAttempts >= MAX_DEV_RESTART_ATTEMPTS) {
|
|
581
|
+
state.devStatus = 'failed';
|
|
582
|
+
state.devError = reason;
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
devRestartAttempts += 1;
|
|
586
|
+
state.devStatus = 'starting';
|
|
587
|
+
state.devError = null;
|
|
588
|
+
appendLog(
|
|
589
|
+
'dev',
|
|
590
|
+
`\nPreview server stopped during navigation (${reason}). Restarting (${devRestartAttempts}/${MAX_DEV_RESTART_ATTEMPTS})...\n`,
|
|
591
|
+
);
|
|
592
|
+
devRestartTimer = setTimeout(() => {
|
|
593
|
+
devRestartTimer = null;
|
|
594
|
+
startPreview();
|
|
595
|
+
}, 900);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function runInstallThenPreview() {
|
|
599
|
+
const nodeModulesPath = path.join(options.templatePath, 'node_modules');
|
|
600
|
+
if (!options.skipInstall && !fs.existsSync(nodeModulesPath)) {
|
|
601
|
+
state.devStatus = 'installing';
|
|
602
|
+
const installCommand =
|
|
603
|
+
typeof manifest.installCommand === 'string' && manifest.installCommand.trim()
|
|
604
|
+
? manifest.installCommand.trim()
|
|
605
|
+
: 'npm install';
|
|
606
|
+
appendLog('dev', `Installing local dependencies with: ${installCommand}\n`);
|
|
607
|
+
const install = spawn(installCommand, {
|
|
608
|
+
cwd: options.templatePath,
|
|
609
|
+
env: process.env,
|
|
610
|
+
shell: true,
|
|
611
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
612
|
+
});
|
|
613
|
+
devProcess = install;
|
|
614
|
+
install.stdout.on('data', (chunk) => appendLog('dev', chunk));
|
|
615
|
+
install.stderr.on('data', (chunk) => appendLog('dev', chunk));
|
|
616
|
+
install.on('error', (error) => {
|
|
617
|
+
state.devStatus = 'failed';
|
|
618
|
+
state.devError = error.message;
|
|
619
|
+
devProcess = null;
|
|
620
|
+
});
|
|
621
|
+
install.on('close', (code) => {
|
|
622
|
+
devProcess = null;
|
|
623
|
+
if (code !== 0) {
|
|
624
|
+
state.devStatus = 'failed';
|
|
625
|
+
state.devError = `Dependency installation exited with code ${code}.`;
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
startPreview();
|
|
629
|
+
});
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
startPreview();
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function startPreview() {
|
|
637
|
+
state.devStatus = 'starting';
|
|
638
|
+
state.devError = null;
|
|
639
|
+
appendLog(
|
|
640
|
+
'dev',
|
|
641
|
+
`Starting template source server on ${sourcePreviewUrl}. Source edits will hot reload.\n`,
|
|
642
|
+
);
|
|
643
|
+
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
644
|
+
devProcess = spawn(
|
|
645
|
+
npmCmd,
|
|
646
|
+
[
|
|
647
|
+
'run',
|
|
648
|
+
'dev',
|
|
649
|
+
'--',
|
|
650
|
+
'--hostname',
|
|
651
|
+
LOOPBACK_HOST,
|
|
652
|
+
'--port',
|
|
653
|
+
String(options.previewPort),
|
|
654
|
+
],
|
|
655
|
+
{
|
|
656
|
+
cwd: options.templatePath,
|
|
657
|
+
env: {
|
|
658
|
+
...process.env,
|
|
659
|
+
NEXT_PUBLIC_SITE_BASE_PATH: '',
|
|
660
|
+
},
|
|
661
|
+
shell: process.platform === 'win32',
|
|
662
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
663
|
+
},
|
|
664
|
+
);
|
|
665
|
+
devProcess.stdout.on('data', (chunk) => appendLog('dev', chunk));
|
|
666
|
+
devProcess.stderr.on('data', (chunk) => appendLog('dev', chunk));
|
|
667
|
+
devProcess.on('error', (error) => {
|
|
668
|
+
state.devStatus = 'failed';
|
|
669
|
+
state.devError = error.message;
|
|
670
|
+
devProcess = null;
|
|
671
|
+
});
|
|
672
|
+
devProcess.on('close', (code, signal) => {
|
|
673
|
+
devProcess = null;
|
|
674
|
+
if (!shuttingDown) {
|
|
675
|
+
const reason = signal
|
|
676
|
+
? `signal ${signal}`
|
|
677
|
+
: `exit code ${code}`;
|
|
678
|
+
scheduleDevRestart(reason);
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
probePreview();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function previewShellHtml() {
|
|
685
|
+
return `<!doctype html>
|
|
14
686
|
<html lang="en">
|
|
15
687
|
<head>
|
|
16
688
|
<meta charset="utf-8">
|
|
@@ -28,7 +700,7 @@ Preview server stopped during navigation (${e}). Restarting (${b}/${k})...
|
|
|
28
700
|
<iframe id="template-preview" title="Local template preview"></iframe>
|
|
29
701
|
<script>
|
|
30
702
|
(() => {
|
|
31
|
-
const BRIDGE_SOURCE = ${JSON.stringify(
|
|
703
|
+
const BRIDGE_SOURCE = ${JSON.stringify(LOCAL_VISUAL_BRIDGE_SCRIPT)};
|
|
32
704
|
const preview = document.getElementById('template-preview');
|
|
33
705
|
const errorBox = document.getElementById('bridge-error');
|
|
34
706
|
const PREVIOUS_PREVIEW_PREFIX = ['MARKET', 'PLACE'].join('') + '_PREVIEW_';
|
|
@@ -112,25 +784,247 @@ Preview server stopped during navigation (${e}). Restarting (${b}/${k})...
|
|
|
112
784
|
|
|
113
785
|
preview.src = '/';
|
|
114
786
|
})();
|
|
115
|
-
|
|
787
|
+
</script>
|
|
116
788
|
</body>
|
|
117
|
-
</html
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
789
|
+
</html>`;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function ignoreAbortedStreamError(error) {
|
|
793
|
+
if (!error || typeof error !== 'object') return false;
|
|
794
|
+
const code = 'code' in error ? String(error.code) : '';
|
|
795
|
+
return (
|
|
796
|
+
code === 'ECONNRESET' ||
|
|
797
|
+
code === 'ECONNABORTED' ||
|
|
798
|
+
code === 'EPIPE' ||
|
|
799
|
+
code === 'ERR_STREAM_DESTROYED'
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function proxyPreviewRequest(request, response) {
|
|
804
|
+
const headers = { ...request.headers };
|
|
805
|
+
headers.host = `${LOOPBACK_HOST}:${options.previewPort}`;
|
|
806
|
+
delete headers['accept-encoding'];
|
|
807
|
+
delete headers.authorization;
|
|
808
|
+
|
|
809
|
+
const upstream = http.request(
|
|
810
|
+
{
|
|
811
|
+
hostname: LOOPBACK_HOST,
|
|
812
|
+
port: options.previewPort,
|
|
813
|
+
method: request.method,
|
|
814
|
+
path: request.url,
|
|
815
|
+
headers,
|
|
816
|
+
},
|
|
817
|
+
(upstreamResponse) => {
|
|
818
|
+
response.writeHead(
|
|
819
|
+
upstreamResponse.statusCode || 502,
|
|
820
|
+
upstreamResponse.statusMessage,
|
|
821
|
+
upstreamResponse.headers,
|
|
822
|
+
);
|
|
823
|
+
upstreamResponse.on('error', (error) => {
|
|
824
|
+
if (!ignoreAbortedStreamError(error)) {
|
|
825
|
+
appendLog('dev', `\nPreview proxy upstream error: ${error.message}\n`);
|
|
826
|
+
}
|
|
827
|
+
if (!response.writableEnded) response.destroy();
|
|
828
|
+
});
|
|
829
|
+
response.on('error', (error) => {
|
|
830
|
+
if (!ignoreAbortedStreamError(error)) {
|
|
831
|
+
appendLog('dev', `\nPreview proxy response error: ${error.message}\n`);
|
|
832
|
+
}
|
|
833
|
+
upstreamResponse.destroy();
|
|
834
|
+
});
|
|
835
|
+
upstreamResponse.pipe(response);
|
|
836
|
+
},
|
|
837
|
+
);
|
|
838
|
+
upstream.on('error', (error) => {
|
|
839
|
+
if (!ignoreAbortedStreamError(error)) {
|
|
840
|
+
appendLog('dev', `\nPreview proxy request error: ${error.message}\n`);
|
|
841
|
+
}
|
|
842
|
+
if (!response.headersSent) {
|
|
843
|
+
sendJson(response, 502, {
|
|
844
|
+
message: `Local preview is not ready: ${error.message}`,
|
|
845
|
+
});
|
|
846
|
+
} else if (!response.writableEnded) {
|
|
847
|
+
response.destroy();
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
request.on('error', (error) => {
|
|
851
|
+
if (!ignoreAbortedStreamError(error)) {
|
|
852
|
+
appendLog('dev', `\nPreview proxy client error: ${error.message}\n`);
|
|
853
|
+
}
|
|
854
|
+
upstream.destroy();
|
|
855
|
+
});
|
|
856
|
+
request.pipe(upstream);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const server = http.createServer((request, response) => {
|
|
860
|
+
const url = new URL(request.url || '/', apiUrl);
|
|
861
|
+
if (request.method === 'GET' && url.pathname === LOCAL_PREVIEW_ROUTE) {
|
|
862
|
+
const html = previewShellHtml();
|
|
863
|
+
response.writeHead(200, {
|
|
864
|
+
'Cache-Control': 'no-store',
|
|
865
|
+
'Content-Length': Buffer.byteLength(html),
|
|
866
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
867
|
+
});
|
|
868
|
+
response.end(html);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (!url.pathname.startsWith('/api/')) {
|
|
872
|
+
proxyPreviewRequest(request, response);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
setCorsHeaders(request, response);
|
|
877
|
+
if (request.method === 'OPTIONS') {
|
|
878
|
+
response.statusCode = 204;
|
|
879
|
+
response.end();
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (!isAuthorized(request)) {
|
|
884
|
+
sendJson(response, 401, { message: 'Invalid Local Template Lab token.' });
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (request.method === 'GET' && url.pathname === '/api/status') {
|
|
889
|
+
sendJson(response, 200, statusPayload());
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (request.method === 'GET' && url.pathname === '/api/site-data') {
|
|
894
|
+
try {
|
|
895
|
+
sendJson(response, 200, {
|
|
896
|
+
manifest,
|
|
897
|
+
siteData: readSiteData(),
|
|
898
|
+
siteDataFile: manifest.siteDataFile,
|
|
899
|
+
});
|
|
900
|
+
} catch (error) {
|
|
901
|
+
sendJson(response, 500, {
|
|
902
|
+
message: error instanceof Error ? error.message : 'Unable to read site data.',
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (request.method === 'POST' && url.pathname === '/api/validate') {
|
|
909
|
+
if (!startValidation()) {
|
|
910
|
+
sendJson(response, 409, { message: 'Validation is already running.' });
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
sendJson(response, 202, statusPayload());
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
sendJson(response, 404, { message: 'Local Template Lab endpoint not found.' });
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
function pipeProxySockets(clientSocket, upstreamSocket) {
|
|
921
|
+
const closeBoth = () => {
|
|
922
|
+
if (!clientSocket.destroyed) clientSocket.destroy();
|
|
923
|
+
if (!upstreamSocket.destroyed) upstreamSocket.destroy();
|
|
924
|
+
};
|
|
925
|
+
const onSocketError = (error) => {
|
|
926
|
+
if (!ignoreAbortedStreamError(error)) {
|
|
927
|
+
appendLog('dev', `\nPreview proxy socket error: ${error.message}\n`);
|
|
928
|
+
}
|
|
929
|
+
closeBoth();
|
|
930
|
+
};
|
|
931
|
+
clientSocket.on('error', onSocketError);
|
|
932
|
+
upstreamSocket.on('error', onSocketError);
|
|
933
|
+
clientSocket.on('close', () => {
|
|
934
|
+
if (!upstreamSocket.destroyed) upstreamSocket.end();
|
|
935
|
+
});
|
|
936
|
+
upstreamSocket.on('close', () => {
|
|
937
|
+
if (!clientSocket.destroyed) clientSocket.end();
|
|
938
|
+
});
|
|
939
|
+
upstreamSocket.pipe(clientSocket);
|
|
940
|
+
clientSocket.pipe(upstreamSocket);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
server.on('upgrade', (request, socket, head) => {
|
|
944
|
+
if (request.url?.startsWith('/api/')) {
|
|
945
|
+
socket.destroy();
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
socket.on('error', (error) => {
|
|
949
|
+
if (!ignoreAbortedStreamError(error)) {
|
|
950
|
+
appendLog('dev', `\nPreview proxy upgrade client error: ${error.message}\n`);
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
const headers = { ...request.headers };
|
|
954
|
+
headers.host = `${LOOPBACK_HOST}:${options.previewPort}`;
|
|
955
|
+
const upstreamRequest = http.request({
|
|
956
|
+
hostname: LOOPBACK_HOST,
|
|
957
|
+
port: options.previewPort,
|
|
958
|
+
method: request.method,
|
|
959
|
+
path: request.url,
|
|
960
|
+
headers,
|
|
961
|
+
});
|
|
962
|
+
upstreamRequest.on('upgrade', (upstreamResponse, upstreamSocket, upstreamHead) => {
|
|
963
|
+
const responseHeaders = Object.entries(upstreamResponse.headers)
|
|
964
|
+
.flatMap(([name, value]) => {
|
|
965
|
+
const values = Array.isArray(value) ? value : [value];
|
|
966
|
+
return values
|
|
967
|
+
.filter((entry) => entry !== undefined)
|
|
968
|
+
.map((entry) => `${name}: ${entry}`);
|
|
969
|
+
})
|
|
970
|
+
.join('\r\n');
|
|
971
|
+
socket.write(
|
|
972
|
+
`HTTP/1.1 ${upstreamResponse.statusCode || 101} ${upstreamResponse.statusMessage || 'Switching Protocols'}\r\n${responseHeaders}\r\n\r\n`,
|
|
973
|
+
);
|
|
974
|
+
if (head.length) upstreamSocket.write(head);
|
|
975
|
+
if (upstreamHead.length) socket.write(upstreamHead);
|
|
976
|
+
pipeProxySockets(socket, upstreamSocket);
|
|
977
|
+
});
|
|
978
|
+
upstreamRequest.on('error', (error) => {
|
|
979
|
+
if (!ignoreAbortedStreamError(error)) {
|
|
980
|
+
appendLog('dev', `\nPreview proxy upgrade upstream error: ${error.message}\n`);
|
|
981
|
+
}
|
|
982
|
+
if (!socket.destroyed) socket.destroy();
|
|
983
|
+
});
|
|
984
|
+
upstreamRequest.end();
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
function stopChild(child) {
|
|
988
|
+
if (!child || child.killed) return;
|
|
989
|
+
child.kill('SIGTERM');
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function shutdown() {
|
|
993
|
+
if (shuttingDown) return;
|
|
994
|
+
shuttingDown = true;
|
|
995
|
+
if (readinessTimer) clearTimeout(readinessTimer);
|
|
996
|
+
if (devRestartTimer) clearTimeout(devRestartTimer);
|
|
997
|
+
stopChild(validationProcess);
|
|
998
|
+
stopChild(devProcess);
|
|
999
|
+
server.close(() => process.exit(0));
|
|
1000
|
+
setTimeout(() => process.exit(0), 1500).unref();
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
server.on('error', (error) => {
|
|
1004
|
+
fail(
|
|
1005
|
+
`Unable to start loopback controller at ${apiUrl}: ${
|
|
1006
|
+
error instanceof Error ? error.message : 'unknown error'
|
|
1007
|
+
}`,
|
|
1008
|
+
);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
server.listen(options.apiPort, LOOPBACK_HOST, () => {
|
|
1012
|
+
process.stdout.write(
|
|
1013
|
+
[
|
|
1014
|
+
'',
|
|
1015
|
+
'Fivora Local Template Lab is running.',
|
|
1016
|
+
`Template: ${state.templateName}`,
|
|
1017
|
+
`Controller URL: ${apiUrl}`,
|
|
1018
|
+
`Connection token: ${controllerToken}`,
|
|
1019
|
+
`Preview URL: ${previewUrl}`,
|
|
1020
|
+
'',
|
|
1021
|
+
'Paste the controller URL and connection token into Developer Portal > Local Test Lab.',
|
|
1022
|
+
'Press Ctrl+C to stop. No template files are uploaded by this process.',
|
|
1023
|
+
'',
|
|
1024
|
+
].join('\n'),
|
|
1025
|
+
);
|
|
1026
|
+
runInstallThenPreview();
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
process.on('SIGINT', shutdown);
|
|
1030
|
+
process.on('SIGTERM', shutdown);
|