instant-cli 1.0.60 → 1.0.61
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/.turbo/turbo-build.log +1 -1
- package/__tests__/backupDownload.test.ts +206 -0
- package/__tests__/backups.test.ts +221 -0
- package/dist/commands/backup/download.d.ts +10 -0
- package/dist/commands/backup/download.d.ts.map +1 -0
- package/dist/commands/backup/download.js +174 -0
- package/dist/commands/backup/download.js.map +1 -0
- package/dist/commands/backup/list.d.ts +11 -0
- package/dist/commands/backup/list.d.ts.map +1 -0
- package/dist/commands/backup/list.js +74 -0
- package/dist/commands/backup/list.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/backupDownload.d.ts +21 -0
- package/dist/lib/backupDownload.d.ts.map +1 -0
- package/dist/lib/backupDownload.js +113 -0
- package/dist/lib/backupDownload.js.map +1 -0
- package/dist/lib/backups.d.ts +12 -0
- package/dist/lib/backups.d.ts.map +1 -0
- package/dist/lib/backups.js +26 -0
- package/dist/lib/backups.js.map +1 -0
- package/dist/lib/platformApi.d.ts +5 -0
- package/dist/lib/platformApi.d.ts.map +1 -0
- package/dist/lib/platformApi.js +11 -0
- package/dist/lib/platformApi.js.map +1 -0
- package/dist/lib/webhooks.d.ts +4 -5
- package/dist/lib/webhooks.d.ts.map +1 -1
- package/dist/lib/webhooks.js +2 -9
- package/dist/lib/webhooks.js.map +1 -1
- package/dist/ui/lib.d.ts +1 -1
- package/dist/ui/lib.d.ts.map +1 -1
- package/dist/ui/lib.js.map +1 -1
- package/package.json +5 -4
- package/src/commands/backup/download.ts +231 -0
- package/src/commands/backup/list.ts +94 -0
- package/src/index.ts +60 -0
- package/src/lib/backupDownload.ts +145 -0
- package/src/lib/backups.ts +33 -0
- package/src/lib/platformApi.ts +11 -0
- package/src/lib/webhooks.ts +1 -10
- package/src/ui/lib.ts +1 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { formatFileSize, type AppBackup } from '@instantdb/platform';
|
|
4
|
+
import type { backupListDef, OptsFromCommand } from '../../index.ts';
|
|
5
|
+
import { useBackupsManager } from '../../lib/backups.ts';
|
|
6
|
+
|
|
7
|
+
export const formatBackupDate = (date: Date) =>
|
|
8
|
+
`${date.toISOString().replace('T', ' ').slice(0, 16)} UTC`;
|
|
9
|
+
|
|
10
|
+
// Backup descriptions are user-controlled text headed for the terminal;
|
|
11
|
+
// strip control characters so a crafted value can't inject escape sequences.
|
|
12
|
+
export const stripControlChars = (s: string) => s.replace(/\p{Cc}/gu, '');
|
|
13
|
+
|
|
14
|
+
// Relative times in both directions: "3 hours ago", "6 days from now".
|
|
15
|
+
export const relativeTime = (date: Date): string => {
|
|
16
|
+
const diffMs = date.getTime() - Date.now();
|
|
17
|
+
const abs = Math.abs(diffMs);
|
|
18
|
+
if (abs < 60_000) return diffMs <= 0 ? 'just now' : 'now';
|
|
19
|
+
const minutes = Math.round(abs / 60_000);
|
|
20
|
+
const hours = Math.round(abs / 3_600_000);
|
|
21
|
+
const days = Math.round(abs / 86_400_000);
|
|
22
|
+
const [count, unit] =
|
|
23
|
+
minutes < 60
|
|
24
|
+
? [minutes, 'minute']
|
|
25
|
+
: hours < 24
|
|
26
|
+
? [hours, 'hour']
|
|
27
|
+
: [days, 'day'];
|
|
28
|
+
const label = `${count} ${unit}${count === 1 ? '' : 's'}`;
|
|
29
|
+
return diffMs < 0 ? `${label} ago` : `${label} from now`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// One aligned row per backup, newest first: id first, relative times.
|
|
33
|
+
// `--json` carries the precise values.
|
|
34
|
+
export const renderBackupsTable = (backups: AppBackup[]) =>
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
const header = [
|
|
37
|
+
'ID',
|
|
38
|
+
'CREATED AT',
|
|
39
|
+
'DB SIZE',
|
|
40
|
+
'STORAGE',
|
|
41
|
+
'EXPIRES AT',
|
|
42
|
+
'DESCRIPTION',
|
|
43
|
+
];
|
|
44
|
+
const rows = backups.map((backup) => [
|
|
45
|
+
backup.id,
|
|
46
|
+
relativeTime(backup.backupAt),
|
|
47
|
+
backup.dbSize != null ? formatFileSize(backup.dbSize) : '-',
|
|
48
|
+
backup.filesSize != null ? formatFileSize(backup.filesSize) : '-',
|
|
49
|
+
backup.expiresAt
|
|
50
|
+
? backup.expiresAt.getTime() <= Date.now()
|
|
51
|
+
? 'expired'
|
|
52
|
+
: relativeTime(backup.expiresAt)
|
|
53
|
+
: '-',
|
|
54
|
+
backup.description ? stripControlChars(backup.description) : '',
|
|
55
|
+
]);
|
|
56
|
+
const widths = header.map((h, i) =>
|
|
57
|
+
Math.max(h.length, ...rows.map((row) => row[i].length)),
|
|
58
|
+
);
|
|
59
|
+
const line = (cells: string[]) =>
|
|
60
|
+
cells
|
|
61
|
+
.map((cell, i) => cell.padEnd(widths[i]))
|
|
62
|
+
.join(' ')
|
|
63
|
+
.trimEnd();
|
|
64
|
+
yield* Effect.log(chalk.dim(line(header)));
|
|
65
|
+
yield* Effect.log(chalk.dim(widths.map((w) => '-'.repeat(w)).join(' ')));
|
|
66
|
+
for (const row of rows) {
|
|
67
|
+
yield* Effect.log(line(row));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const backupListCmd = Effect.fn(function* (
|
|
72
|
+
opts: OptsFromCommand<typeof backupListDef>,
|
|
73
|
+
) {
|
|
74
|
+
const backups = yield* useBackupsManager(
|
|
75
|
+
(m) => m.list(),
|
|
76
|
+
'Error listing backups',
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (opts.json) {
|
|
80
|
+
yield* Effect.log(JSON.stringify(backups, null, 2));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (backups.length === 0) {
|
|
85
|
+
yield* Effect.log('No backups yet.');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The server returns newest first; sort anyway so the table can't lie.
|
|
90
|
+
const sorted = [...backups].sort(
|
|
91
|
+
(a, b) => b.backupAt.getTime() - a.backupAt.getTime(),
|
|
92
|
+
);
|
|
93
|
+
yield* renderBackupsTable(sorted);
|
|
94
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -51,6 +51,8 @@ import { webhooksEventsResendCmd } from './commands/webhooks/events/resend.ts';
|
|
|
51
51
|
import { emailStatusCmd } from './commands/auth/email/status.ts';
|
|
52
52
|
import { verifyCmd } from './commands/auth/email/verify.ts';
|
|
53
53
|
import { resendEmailCmd } from './commands/auth/email/resend.ts';
|
|
54
|
+
import { backupListCmd } from './commands/backup/list.ts';
|
|
55
|
+
import { backupDownloadCmd } from './commands/backup/download.ts';
|
|
54
56
|
|
|
55
57
|
export type OptsFromCommand<C> =
|
|
56
58
|
C extends Command<any, infer R, any> ? R : never;
|
|
@@ -640,6 +642,64 @@ export const webhooksEventsPayloadDef = webhooksEvents
|
|
|
640
642
|
);
|
|
641
643
|
});
|
|
642
644
|
|
|
645
|
+
const backup = program
|
|
646
|
+
.command('backup')
|
|
647
|
+
.description('View and download backups of your app');
|
|
648
|
+
|
|
649
|
+
export const backupListDef = backup
|
|
650
|
+
.command('list')
|
|
651
|
+
.description('List downloadable backups for an app')
|
|
652
|
+
.option(
|
|
653
|
+
'-a --app <app-id>',
|
|
654
|
+
'App ID to list backups for. Defaults to *_INSTANT_APP_ID in .env',
|
|
655
|
+
)
|
|
656
|
+
.option('--json', 'Output backups as JSON')
|
|
657
|
+
.action((opts) => {
|
|
658
|
+
return runCommandEffect(
|
|
659
|
+
backupListCmd(opts).pipe(
|
|
660
|
+
Effect.provide(
|
|
661
|
+
WithAppLayer({
|
|
662
|
+
coerce: false,
|
|
663
|
+
coerceAuth: false,
|
|
664
|
+
appId: opts.app,
|
|
665
|
+
allowAdminToken: true,
|
|
666
|
+
}).pipe(Layer.annotateLogs('silent', !!opts.json)),
|
|
667
|
+
),
|
|
668
|
+
),
|
|
669
|
+
);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
export const backupDownloadDef = backup
|
|
673
|
+
.command('download')
|
|
674
|
+
.description('Download a backup as a zip file')
|
|
675
|
+
.argument(
|
|
676
|
+
'[backup-id]',
|
|
677
|
+
'Backup ID to download. Defaults to an interactive picker',
|
|
678
|
+
)
|
|
679
|
+
.option(
|
|
680
|
+
'-a --app <app-id>',
|
|
681
|
+
'App ID to download a backup of. Defaults to *_INSTANT_APP_ID in .env',
|
|
682
|
+
)
|
|
683
|
+
.option('--latest', 'Download the most recent backup')
|
|
684
|
+
.option(
|
|
685
|
+
'-o --out <path>',
|
|
686
|
+
'Output zip path. Defaults to instant-backup-<timestamp>.zip',
|
|
687
|
+
)
|
|
688
|
+
.action((backupId, opts) => {
|
|
689
|
+
return runCommandEffect(
|
|
690
|
+
backupDownloadCmd(backupId, opts).pipe(
|
|
691
|
+
Effect.provide(
|
|
692
|
+
WithAppLayer({
|
|
693
|
+
coerce: false,
|
|
694
|
+
coerceAuth: false,
|
|
695
|
+
appId: opts.app,
|
|
696
|
+
allowAdminToken: true,
|
|
697
|
+
}),
|
|
698
|
+
),
|
|
699
|
+
),
|
|
700
|
+
);
|
|
701
|
+
});
|
|
702
|
+
|
|
643
703
|
const authEmail = auth
|
|
644
704
|
.command('email')
|
|
645
705
|
.description('Manage custom magic code email templates');
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createWriteStream } from 'node:fs';
|
|
2
|
+
import { open, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { once } from 'node:events';
|
|
5
|
+
import { get as httpGet, type IncomingMessage } from 'node:http';
|
|
6
|
+
import { get as httpsGet } from 'node:https';
|
|
7
|
+
import { Readable, Writable, type Duplex } from 'node:stream';
|
|
8
|
+
import zlib from 'node:zlib';
|
|
9
|
+
import type {
|
|
10
|
+
AppBackup,
|
|
11
|
+
BackupArchiveWriter,
|
|
12
|
+
BackupDownloadProgress,
|
|
13
|
+
BackupDownloadResult,
|
|
14
|
+
BackupsManager,
|
|
15
|
+
} from '@instantdb/platform';
|
|
16
|
+
|
|
17
|
+
export type {
|
|
18
|
+
BackupDownloadProgress,
|
|
19
|
+
BackupDownloadResult,
|
|
20
|
+
} from '@instantdb/platform';
|
|
21
|
+
|
|
22
|
+
// zstd landed in node:zlib in 22.15 / 23.8; on older Nodes the property is
|
|
23
|
+
// absent, so feature-detect instead of assuming the type declarations match
|
|
24
|
+
// the runtime.
|
|
25
|
+
const createZstdDecompress: (() => Duplex) | undefined = (
|
|
26
|
+
zlib as { createZstdDecompress?: () => Duplex }
|
|
27
|
+
).createZstdDecompress;
|
|
28
|
+
|
|
29
|
+
function fetchStream(
|
|
30
|
+
url: string,
|
|
31
|
+
signal: AbortSignal,
|
|
32
|
+
): Promise<IncomingMessage> {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const get = url.startsWith('https:') ? httpsGet : httpGet;
|
|
35
|
+
const req = get(url, { signal }, resolve);
|
|
36
|
+
req.on('error', reject);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// .pipe doesn't forward errors, so a source failure would otherwise leave the
|
|
41
|
+
// destination (and the zip writer reading from it) hanging forever.
|
|
42
|
+
function pipe(src: Readable, dst: Duplex): Duplex {
|
|
43
|
+
src.on('error', (e) => dst.destroy(e));
|
|
44
|
+
return src.pipe(dst);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Fetches a presigned URL with node:http(s), decompressing explicitly: the
|
|
48
|
+
// entity shards are served with `Content-Encoding: zstd` and Node doesn't
|
|
49
|
+
// auto-decompress that. downloadBackupToFile refuses to run without zstd
|
|
50
|
+
// support, so the assertion below can't fire.
|
|
51
|
+
async function fetchBody(
|
|
52
|
+
url: string,
|
|
53
|
+
signal: AbortSignal,
|
|
54
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
55
|
+
const res = await fetchStream(url, signal);
|
|
56
|
+
if (res.statusCode !== 200) {
|
|
57
|
+
res.resume();
|
|
58
|
+
throw new Error(`HTTP ${res.statusCode}`);
|
|
59
|
+
}
|
|
60
|
+
const encoding = res.headers['content-encoding'];
|
|
61
|
+
let stream: Readable = res;
|
|
62
|
+
if (encoding === 'zstd') {
|
|
63
|
+
stream = pipe(stream, createZstdDecompress!());
|
|
64
|
+
} else if (encoding === 'gzip') {
|
|
65
|
+
stream = pipe(stream, zlib.createGunzip());
|
|
66
|
+
} else if (encoding) {
|
|
67
|
+
res.destroy();
|
|
68
|
+
throw new Error(`Unsupported content encoding: ${encoding}`);
|
|
69
|
+
}
|
|
70
|
+
return Readable.toWeb(stream) as ReadableStream<Uint8Array>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function createZipWriter(
|
|
74
|
+
sink: WritableStream<Uint8Array>,
|
|
75
|
+
signal: AbortSignal,
|
|
76
|
+
): Promise<BackupArchiveWriter> {
|
|
77
|
+
// Loaded on demand so every other CLI command skips parsing it.
|
|
78
|
+
const { ZipWriter } = await import('@zip.js/zip.js');
|
|
79
|
+
// zip64: without it any archive whose central-directory offset passes 4GB
|
|
80
|
+
// writes a wrapped 32-bit offset and the zip is unreadable.
|
|
81
|
+
return new ZipWriter(sink, { zip64: true, signal });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Downloads a backup into a zip file at `outPath` via the shared
|
|
86
|
+
* `BackupsManager.downloadArchive` pipeline, supplying the Node-specific
|
|
87
|
+
* pieces:
|
|
88
|
+
* presigned URLs are fetched with node:http(s) and decompressed explicitly,
|
|
89
|
+
* and the archive streams to disk with backpressure so memory stays flat
|
|
90
|
+
* regardless of backup size.
|
|
91
|
+
*
|
|
92
|
+
* Writes to `<outPath>.partial` and renames on success; a failed or aborted
|
|
93
|
+
* download removes the partial file.
|
|
94
|
+
*/
|
|
95
|
+
export async function downloadBackupToFile(opts: {
|
|
96
|
+
manager: BackupsManager;
|
|
97
|
+
backup: AppBackup;
|
|
98
|
+
outPath: string;
|
|
99
|
+
signal: AbortSignal;
|
|
100
|
+
onProgress: (progress: BackupDownloadProgress) => void;
|
|
101
|
+
}): Promise<BackupDownloadResult> {
|
|
102
|
+
// The entity shards are served with `Content-Encoding: zstd`; fail before
|
|
103
|
+
// writing anything if this Node can't decompress them.
|
|
104
|
+
if (!createZstdDecompress) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
'Downloading backups requires Node 22.15 or newer (for zstd support).',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Randomized so a stale partial or a concurrent download of the same
|
|
111
|
+
// backup can't collide; 'wx' turns any remaining collision into an error
|
|
112
|
+
// instead of silently truncating another run's file.
|
|
113
|
+
const partialPath = `${opts.outPath}.partial-${randomBytes(4).toString('hex')}`;
|
|
114
|
+
const fileStream = createWriteStream(partialPath, { flags: 'wx' });
|
|
115
|
+
const awaitFileClosed = async () => {
|
|
116
|
+
if (!fileStream.closed) await once(fileStream, 'close');
|
|
117
|
+
};
|
|
118
|
+
try {
|
|
119
|
+
const result = await opts.manager.downloadArchive({
|
|
120
|
+
backup: opts.backup,
|
|
121
|
+
fetchBody,
|
|
122
|
+
sink: Writable.toWeb(fileStream) as WritableStream<Uint8Array>,
|
|
123
|
+
createWriter: createZipWriter,
|
|
124
|
+
signal: opts.signal,
|
|
125
|
+
onProgress: opts.onProgress,
|
|
126
|
+
});
|
|
127
|
+
await awaitFileClosed();
|
|
128
|
+
// Flush to disk before the rename so a crash right after can't leave a
|
|
129
|
+
// complete-looking zip with unwritten tails.
|
|
130
|
+
const fh = await open(partialPath, 'r+');
|
|
131
|
+
try {
|
|
132
|
+
await fh.sync();
|
|
133
|
+
} finally {
|
|
134
|
+
await fh.close();
|
|
135
|
+
}
|
|
136
|
+
await rename(partialPath, opts.outPath);
|
|
137
|
+
return result;
|
|
138
|
+
} catch (e) {
|
|
139
|
+
// The pipeline already aborted the sink; wait for the fd to close, then
|
|
140
|
+
// discard the partial file on disk.
|
|
141
|
+
await awaitFileClosed().catch(() => {});
|
|
142
|
+
await unlink(partialPath).catch(() => {});
|
|
143
|
+
throw e;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import type { BackupsManager } from '@instantdb/platform';
|
|
3
|
+
import { CurrentApp } from '../context/currentApp.ts';
|
|
4
|
+
import { PlatformApiError } from '../context/platformApi.ts';
|
|
5
|
+
import { getAuthedPlatformApi } from './platformApi.ts';
|
|
6
|
+
|
|
7
|
+
export const useBackupsManager = <R>(
|
|
8
|
+
fun: (manager: BackupsManager) => Promise<R>,
|
|
9
|
+
errorMessage?: string,
|
|
10
|
+
) =>
|
|
11
|
+
Effect.gen(function* () {
|
|
12
|
+
const api = yield* getAuthedPlatformApi;
|
|
13
|
+
const { appId } = yield* CurrentApp;
|
|
14
|
+
return yield* Effect.tryPromise({
|
|
15
|
+
try: () => fun(api.backups(appId)),
|
|
16
|
+
catch: (e) =>
|
|
17
|
+
new PlatformApiError({
|
|
18
|
+
message: errorMessage ?? 'Error using backups api',
|
|
19
|
+
cause: e,
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Yields a `BackupsManager` instance scoped to the current app. Use when you
|
|
26
|
+
* need to hold on to the manager outside an Effect (e.g. to drive the
|
|
27
|
+
* long-running download pipeline).
|
|
28
|
+
*/
|
|
29
|
+
export const buildBackupsManager = Effect.gen(function* () {
|
|
30
|
+
const api = yield* getAuthedPlatformApi;
|
|
31
|
+
const { appId } = yield* CurrentApp;
|
|
32
|
+
return api.backups(appId);
|
|
33
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { PlatformApi as InstantPlatformApi } from '@instantdb/platform';
|
|
3
|
+
import { AuthToken } from '../context/authToken.ts';
|
|
4
|
+
import { getBaseUrl } from './config.ts';
|
|
5
|
+
|
|
6
|
+
export const getAuthedPlatformApi = Effect.gen(function* () {
|
|
7
|
+
const apiURI = yield* getBaseUrl;
|
|
8
|
+
const authToken = yield* AuthToken;
|
|
9
|
+
const token = yield* authToken.getAuthToken;
|
|
10
|
+
return new InstantPlatformApi({ apiURI, auth: { token } });
|
|
11
|
+
});
|
package/src/lib/webhooks.ts
CHANGED
|
@@ -1,22 +1,13 @@
|
|
|
1
1
|
import { Effect } from 'effect';
|
|
2
2
|
import {
|
|
3
|
-
PlatformApi as InstantPlatformApi,
|
|
4
3
|
type WebhookAction,
|
|
5
4
|
type WebhookEventInfo,
|
|
6
5
|
type WebhooksManager,
|
|
7
6
|
} from '@instantdb/platform';
|
|
8
|
-
import { AuthToken } from '../context/authToken.ts';
|
|
9
7
|
import { CurrentApp } from '../context/currentApp.ts';
|
|
10
8
|
import { PlatformApiError } from '../context/platformApi.ts';
|
|
11
9
|
import { BadArgsError } from '../errors.ts';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
const getAuthedPlatformApi = Effect.gen(function* () {
|
|
15
|
-
const apiURI = yield* getBaseUrl;
|
|
16
|
-
const authToken = yield* AuthToken;
|
|
17
|
-
const token = yield* authToken.getAuthToken;
|
|
18
|
-
return new InstantPlatformApi({ apiURI, auth: { token } });
|
|
19
|
-
});
|
|
10
|
+
import { getAuthedPlatformApi } from './platformApi.ts';
|
|
20
11
|
|
|
21
12
|
export const WEBHOOK_ACTIONS: readonly WebhookAction[] = [
|
|
22
13
|
'create',
|
package/src/ui/lib.ts
CHANGED
|
@@ -372,7 +372,7 @@ let terminateHandler:
|
|
|
372
372
|
| undefined;
|
|
373
373
|
|
|
374
374
|
export function onTerminate(
|
|
375
|
-
callback: (stdin: ReadStream, stdout: WriteStream) => void | undefined,
|
|
375
|
+
callback: ((stdin: ReadStream, stdout: WriteStream) => void) | undefined,
|
|
376
376
|
) {
|
|
377
377
|
terminateHandler = callback;
|
|
378
378
|
}
|