Skip to content

Commit 7ac96bb

Browse files
authored
Merge pull request #140 from mi-examples/pp-3311
PP-3311 Fix modal/sync lifecycle and safety checks
2 parents 01e73a7 + 3aaaeb6 commit 7ac96bb

8 files changed

Lines changed: 135 additions & 46 deletions

File tree

src/cli.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1256,16 +1256,22 @@ cli
12561256
return;
12571257
}
12581258

1259-
const latestBackup = backups
1260-
.filter((value) => {
1261-
return value.isFile() && value.name.endsWith('.zip');
1262-
})
1263-
.reduce((latest, current) => {
1264-
const latestTime = fs.statSync(path.resolve(backupsDirPath, latest.name)).mtimeMs;
1265-
const currentTime = fs.statSync(path.resolve(backupsDirPath, current.name)).mtimeMs;
1266-
1267-
return latestTime > currentTime ? latest : current;
1268-
}, backups[0]).name;
1259+
const zipBackups = backups.filter((value) => {
1260+
return value.isFile() && value.name.endsWith('.zip');
1261+
});
1262+
1263+
if (!zipBackups.length) {
1264+
createLogger(options.logLevel).warn(colors.yellow(`no ZIP backups found, skipping changelog generation`));
1265+
1266+
return;
1267+
}
1268+
1269+
const latestBackup = zipBackups.reduce((latest, current) => {
1270+
const latestTime = fs.statSync(path.resolve(backupsDirPath, latest.name)).mtimeMs;
1271+
const currentTime = fs.statSync(path.resolve(backupsDirPath, current.name)).mtimeMs;
1272+
1273+
return latestTime > currentTime ? latest : current;
1274+
}).name;
12691275

12701276
oldAssetsPath = path.resolve(backupsDirPath, latestBackup);
12711277
}

src/client/index.ts

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,25 @@ const POPUP_HEIGHT = 100;
6464
const ANIMATION_DURATION = 300;
6565
const CONFIRM_MODAL_OVERLAY_CLASS = 'pp-dev-info__confirm-overlay';
6666

67+
const activeConfirmModals = new Map<
68+
HTMLDivElement,
69+
{ resolve: (value: boolean) => void; onKeyDown: (event: KeyboardEvent) => void }
70+
>();
71+
72+
function teardownConfirmModal(overlay: HTMLDivElement, result: boolean) {
73+
const entry = activeConfirmModals.get(overlay);
74+
75+
if (!entry) {
76+
return;
77+
}
78+
79+
document.removeEventListener('keydown', entry.onKeyDown);
80+
81+
activeConfirmModals.delete(overlay);
82+
overlay.remove();
83+
entry.resolve(result);
84+
}
85+
6786
function createPopupElement(opts: InfoPopupOptions): HTMLDivElement {
6887
const $popup = document.createElement('div');
6988

@@ -220,64 +239,73 @@ function infoPopup(opts: InfoPopupOptions) {
220239
}
221240
}
222241

223-
function closeConfirmModalByResult(overlay: HTMLDivElement, resolve: (value: boolean) => void, value: boolean) {
224-
overlay.remove();
225-
resolve(value);
226-
}
227-
228242
function closeAllConfirmModals() {
229-
document.querySelectorAll(`.${CONFIRM_MODAL_OVERLAY_CLASS}`).forEach((element) => element.remove());
243+
for (const overlay of [...activeConfirmModals.keys()]) {
244+
teardownConfirmModal(overlay, false);
245+
}
230246
}
231247

232248
function confirmModal(opts: ConfirmModalOptions): Promise<boolean> {
233249
closeAllConfirmModals();
234250

235251
return new Promise<boolean>((resolve) => {
236252
const $overlay = document.createElement('div');
253+
237254
$overlay.classList.add('pp-dev-info-namespace', CONFIRM_MODAL_OVERLAY_CLASS);
255+
238256
const $confirm = document.createElement('div');
257+
239258
$confirm.classList.add('pp-dev-info__confirm');
259+
240260
const $title = document.createElement('div');
261+
241262
$title.classList.add('pp-dev-info__confirm-title');
242263
$title.textContent = opts.title;
264+
243265
const $content = document.createElement('div');
266+
244267
$content.classList.add('pp-dev-info__confirm-content');
245268
$content.textContent = opts.content;
269+
246270
const $actions = document.createElement('div');
271+
247272
$actions.classList.add('pp-dev-info__confirm-actions');
273+
248274
const $cancelButton = document.createElement('button');
275+
249276
$cancelButton.type = 'button';
250277
$cancelButton.classList.add('pp-dev-info__confirm-btn', 'pp-dev-info__confirm-btn--cancel');
251278
$cancelButton.textContent = opts.cancelText;
279+
252280
const $confirmButton = document.createElement('button');
281+
253282
$confirmButton.type = 'button';
254283
$confirmButton.classList.add('pp-dev-info__confirm-btn', 'pp-dev-info__confirm-btn--confirm');
255284
$confirmButton.textContent = opts.confirmText;
285+
256286
$actions.append($cancelButton, $confirmButton);
257287
$confirm.append($title, $content, $actions);
258288
$overlay.appendChild($confirm);
259289

260290
const onKeyDown = (event: KeyboardEvent) => {
261291
if (event.key === 'Escape') {
262-
document.removeEventListener('keydown', onKeyDown);
263-
closeConfirmModalByResult($overlay, resolve, false);
292+
teardownConfirmModal($overlay, false);
264293
}
265294
};
266295

296+
activeConfirmModals.set($overlay, { resolve, onKeyDown });
297+
267298
$confirmButton.addEventListener('click', () => {
268-
document.removeEventListener('keydown', onKeyDown);
269-
closeConfirmModalByResult($overlay, resolve, true);
299+
teardownConfirmModal($overlay, true);
270300
});
271301

272302
$cancelButton.addEventListener('click', () => {
273-
document.removeEventListener('keydown', onKeyDown);
274-
closeConfirmModalByResult($overlay, resolve, false);
303+
teardownConfirmModal($overlay, false);
275304
});
276305

277306
$overlay.addEventListener('click', (event) => {
278307
if (event.target === $overlay) {
279-
document.removeEventListener('keydown', onKeyDown);
280-
closeConfirmModalByResult($overlay, resolve, false);
308+
teardownConfirmModal($overlay, false);
281309
}
282310
});
283311

src/lib/client.service.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ interface SyncActionResponsePayload {
2424
export interface ClientServiceOptions {
2525
distService?: DistService;
2626
miAPI?: MiAPI;
27+
/**
28+
* Max time to wait for the browser to respond to `template:sync:action-required`.
29+
* After this, the pending promise resolves to `false` and the resolver is removed.
30+
* @default 120_000
31+
*/
32+
syncActionTimeoutMs?: number;
2733
}
2834

2935
export class ClientService {
@@ -32,7 +38,10 @@ export class ClientService {
3238
private readonly eventMap: Map<string, (this: ClientService, ...attrs: any[]) => void>;
3339

3440
private logger: Logger;
35-
private readonly syncActionResolvers: Map<string, (approved: boolean) => void> = new Map();
41+
private readonly syncActionResolvers = new Map<
42+
string,
43+
{ resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }
44+
>();
3645

3746
constructor(server: ViteDevServer, opts?: ClientServiceOptions) {
3847
this.server = server;
@@ -55,6 +64,33 @@ export class ClientService {
5564
for (const [event, handler] of this.eventMap) {
5665
ws.on(event, handler);
5766
}
67+
68+
ws.on('close', () => {
69+
this.clearAllPendingSyncActions(false);
70+
});
71+
72+
ws.on('error', () => {
73+
this.clearAllPendingSyncActions(false);
74+
});
75+
}
76+
77+
private resolveSyncAction(requestId: string, approved: boolean) {
78+
const entry = this.syncActionResolvers.get(requestId);
79+
80+
if (!entry) {
81+
return;
82+
}
83+
84+
clearTimeout(entry.timeoutId);
85+
this.syncActionResolvers.delete(requestId);
86+
entry.resolve(approved);
87+
}
88+
89+
/** Resolves every pending `requestSyncAction` promise and clears timeouts (e.g. WebSocket closed). */
90+
private clearAllPendingSyncActions(approved: boolean) {
91+
for (const requestId of [...this.syncActionResolvers.keys()]) {
92+
this.resolveSyncAction(requestId, approved);
93+
}
5894
}
5995

6096
onInfoDataRequest() {
@@ -70,21 +106,19 @@ export class ClientService {
70106
return;
71107
}
72108

73-
const resolver = this.syncActionResolvers.get(payload.requestId);
74-
75-
if (!resolver) {
76-
return;
77-
}
78-
79-
this.syncActionResolvers.delete(payload.requestId);
80-
resolver(payload.approved);
109+
this.resolveSyncAction(payload.requestId, payload.approved);
81110
}
82111

83112
async requestSyncAction(payload: Omit<SyncActionRequestPayload, 'requestId'>) {
84113
const requestId = randomUUID();
114+
const timeoutMs = this.opts.syncActionTimeoutMs ?? 120_000;
85115

86116
return await new Promise<boolean>((resolve) => {
87-
this.syncActionResolvers.set(requestId, resolve);
117+
const timeoutId = setTimeout(() => {
118+
this.resolveSyncAction(requestId, false);
119+
}, timeoutMs);
120+
121+
this.syncActionResolvers.set(requestId, { resolve, timeoutId });
88122

89123
this.server.ws.send('template:sync:action-required', {
90124
...payload,

src/lib/dist.service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,12 @@ export class DistService {
370370
if (resolvedCandidates.length === 1) {
371371
versionManifestPath = resolvedCandidates[0]!;
372372
}
373+
374+
if (buildManifest.compat?.versionFileRequired === true && versionManifestPath === null) {
375+
throw new Error(
376+
`Build manifest requires version file "${buildManifest.versionFile}", but it is missing in backup ZIP`,
377+
);
378+
}
373379
} else {
374380
const versionFileMatcher = this.versionFileTemplateMatcher();
375381
const versionManifestCandidates = relativePaths.filter((filePath) =>

src/lib/pp.middleware.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ export class MiAPI {
152152
return obj;
153153
}
154154

155+
#escapeRegExp(value: string): string {
156+
return value.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&');
157+
}
158+
155159
get personalAccessToken(): string | undefined {
156160
return this.#personalAccessToken;
157161
}
@@ -474,7 +478,10 @@ export class MiAPI {
474478
let result = typeof content === 'string' ? content : content.toString('utf-8');
475479

476480
for (const v of this.#pageVars) {
477-
result = result.replace(new RegExp(`\\[${v.name}\\]`, 'g'), v.value);
481+
const escapedName = this.#escapeRegExp(v.name);
482+
const pageVarRegex = new RegExp(`\\[${escapedName}\\]`, 'g');
483+
484+
result = result.replace(pageVarRegex, () => v.value);
478485
}
479486

480487
const dom = new JSDOM(miHudLess ? result : this.#pageTemplate!);

src/plugins/version-plugin.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ function resolveVersionFileName(template: string, packageVersion: string, curren
8686
return template.replace(/\{packageversion\}/g, packageVersion).replace(/\{currentDate\}/g, currentDate);
8787
}
8888

89+
function stripUrlUserInfo(value: string): string {
90+
return value.replace(/^([a-z][a-z\d+.-]*:\/\/)(?:[^/?#@]+(?::[^/?#@]*)?@)/i, '$1');
91+
}
92+
8993
function normalizeRepositoryUrl(url: string): string {
9094
const normalizedUrl = url
9195
.trim()
@@ -96,26 +100,26 @@ function normalizeRepositoryUrl(url: string): string {
96100
if (scpLikeMatch) {
97101
const [, , host, repositoryPath] = scpLikeMatch;
98102

99-
return `https://${host}/${repositoryPath.replace(/^\/+/, '')}`;
103+
return stripUrlUserInfo(`https://${host}/${repositoryPath.replace(/^\/+/, '')}`);
100104
}
101105

102106
const sshProtocolMatch = normalizedUrl.match(/^ssh:\/\/(?:[^@]+@)?([^/:]+)(?::\d+)?\/(.+)$/i);
103107

104108
if (sshProtocolMatch) {
105109
const [, host, repositoryPath] = sshProtocolMatch;
106110

107-
return `https://${host}/${repositoryPath.replace(/^\/+/, '')}`;
111+
return stripUrlUserInfo(`https://${host}/${repositoryPath.replace(/^\/+/, '')}`);
108112
}
109113

110114
const gitProtocolMatch = normalizedUrl.match(/^git:\/\/([^/]+)\/(.+)$/i);
111115

112116
if (gitProtocolMatch) {
113117
const [, host, repositoryPath] = gitProtocolMatch;
114118

115-
return `https://${host}/${repositoryPath.replace(/^\/+/, '')}`;
119+
return stripUrlUserInfo(`https://${host}/${repositoryPath.replace(/^\/+/, '')}`);
116120
}
117121

118-
return normalizedUrl;
122+
return stripUrlUserInfo(normalizedUrl);
119123
}
120124

121125
function resolveHelperVersion(): string {

src/shortcuts.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,14 @@ export function bindShortcuts(server: ViteDevServer, opts: BindShortcutsOptions)
9494
process.stdin.setRawMode(true);
9595

9696
process.stdin.on('data', onInput).setEncoding('utf8').resume();
97+
const boundServer = server.httpServer;
9798

9899
const cleanupBinding = () => {
100+
if (cleanupActiveShortcutBinding !== cleanupBinding) {
101+
return;
102+
}
103+
104+
boundServer.removeListener('close', cleanupBinding);
99105
process.stdin.off('data', onInput);
100106

101107
if (!hadRawModeEnabled) {
@@ -106,13 +112,11 @@ export function bindShortcuts(server: ViteDevServer, opts: BindShortcutsOptions)
106112
process.stdin.pause();
107113
}
108114

109-
if (cleanupActiveShortcutBinding === cleanupBinding) {
110-
cleanupActiveShortcutBinding = null;
111-
}
115+
cleanupActiveShortcutBinding = null;
112116
};
113117

114118
cleanupActiveShortcutBinding = cleanupBinding;
115-
server.httpServer.on('close', cleanupBinding);
119+
boundServer.on('close', cleanupBinding);
116120
}
117121

118122
const BASE_SHORTCUTS: CLIShortcut[] = [

tests/test-nextjs/package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)