@substrat-run/control-plane-api 0.48.1 → 0.49.0
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/dist/api.d.ts.map +1 -1
- package/dist/api.js +132 -17
- package/dist/api.js.map +1 -1
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +67 -52
- package/dist/cf-observability.js.map +1 -1
- package/dist/deploy.d.ts +23 -1
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js.map +1 -1
- package/dist/observability.d.ts +7 -2
- package/dist/observability.d.ts.map +1 -1
- package/dist/wfp.d.ts.map +1 -1
- package/dist/wfp.js +107 -0
- package/dist/wfp.js.map +1 -1
- package/package.json +4 -4
package/dist/api.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AA+B5B,OAAO,KAAK,EAGV,eAAe,EAIhB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAGtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAe3D,OAAO,KAAK,EAAe,gBAAgB,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACzF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AAQtD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,EAGL,KAAK,yBAAyB,EAC/B,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC3C;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IAChG;;;;;;;;;OASG;IACH,sBAAsB,CAAC,EAAE,CACvB,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,KACnB,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACzC;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACpF;;;;;OAKG;IACH,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,sBAAsB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,qBAAqB,CAAC;IAC5C;;;;;;;;OAQG;IACH,sBAAsB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C;;;;OAIG;IACH,YAAY,EAAE,iBAAiB,CAAC;IAChC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,WAAW,CAAC;IAClC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,yBAAyB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;CAChC;AAKD,KAAK,IAAI,GAAG;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;AA8M7D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CA8oFhG"}
|
package/dist/api.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
-
import { adminAction, channelName, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, listPageQuery, pageOf, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, registerVerticalInput, scopeDump, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, z, } from '@substrat-run/contracts';
|
|
2
|
+
import { adminAction, ASSET_PART_PREFIX, assetHash, channelName, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, listPageQuery, pageOf, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, registerVerticalInput, scopeDump, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, z, } from '@substrat-run/contracts';
|
|
3
3
|
import { migrationProgress, ulid } from '@substrat-run/kernel';
|
|
4
4
|
import { TENANT_HEADER } from './auth.js';
|
|
5
5
|
import { ControlPlaneError } from './client.js';
|
|
@@ -1578,6 +1578,22 @@ export function createControlPlaneApi(options) {
|
|
|
1578
1578
|
const registry = json ? (storedDeployManifest.parse(JSON.parse(json)).registry ?? null) : null;
|
|
1579
1579
|
return c.json({ registry });
|
|
1580
1580
|
});
|
|
1581
|
+
// The static files (#340) one version ships: path, size, content type, content address —
|
|
1582
|
+
// read straight out of the retained manifest, which is where they were persisted for the
|
|
1583
|
+
// promote path anyway. Owner-narrowed exactly like the registry route above. `assets` is
|
|
1584
|
+
// null for a version that retained no manifest (pushed pre-#286) or shipped no static
|
|
1585
|
+
// files; the two are distinguishable by the caller only as "nothing to show", which is
|
|
1586
|
+
// all the dashboard panel needs to render an empty state.
|
|
1587
|
+
app.get('/verticals/:slug/versions/:id/assets', async (c) => {
|
|
1588
|
+
const p = c.get('principal');
|
|
1589
|
+
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
1590
|
+
if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
|
|
1591
|
+
return c.json({ error: 'not found' }, 404);
|
|
1592
|
+
}
|
|
1593
|
+
const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
1594
|
+
const assets = json ? (storedDeployManifest.parse(JSON.parse(json)).assets ?? null) : null;
|
|
1595
|
+
return c.json({ assets });
|
|
1596
|
+
});
|
|
1581
1597
|
app.post('/verticals/:slug/versions/:id/admit', async (c) => {
|
|
1582
1598
|
const slug = c.req.param('slug');
|
|
1583
1599
|
const id = c.req.param('id');
|
|
@@ -1725,6 +1741,13 @@ export function createControlPlaneApi(options) {
|
|
|
1725
1741
|
modules,
|
|
1726
1742
|
doClasses: manifest.doClasses,
|
|
1727
1743
|
bindings: [...manifest.bindings, ...storeBindings],
|
|
1744
|
+
// #340: the version's static files travel with it onto the serving script — from
|
|
1745
|
+
// the RETAINED manifest, with no bytes. An asset upload session is driven by
|
|
1746
|
+
// content addresses, and the runtime's asset store is namespace-wide and deduped,
|
|
1747
|
+
// so re-declaring the same hashes re-attaches the same files. This is why the
|
|
1748
|
+
// manifest is retained rather than the bytes: the archive script gives back the
|
|
1749
|
+
// modules (#286), and the asset store gives back the assets.
|
|
1750
|
+
...(manifest.assets ? { assets: manifest.assets } : {}),
|
|
1728
1751
|
}, serving
|
|
1729
1752
|
? { priorDoClasses: serving.doClasses, priorMigrationTag: serving.migrationTag }
|
|
1730
1753
|
: undefined);
|
|
@@ -1915,21 +1938,72 @@ export function createControlPlaneApi(options) {
|
|
|
1915
1938
|
}, 409);
|
|
1916
1939
|
}
|
|
1917
1940
|
}
|
|
1941
|
+
// Two kinds of part in one body (#340): worker MODULES (unprefixed) and static ASSETS
|
|
1942
|
+
// (`asset:<served path>`). Splitting on the prefix — rather than on content type or on
|
|
1943
|
+
// whether the manifest happens to name it — is what keeps an uploaded file from being
|
|
1944
|
+
// able to enter the wrong pipeline.
|
|
1918
1945
|
const modules = [];
|
|
1946
|
+
const assetParts = new Map();
|
|
1919
1947
|
for (const [name, value] of form.entries()) {
|
|
1920
1948
|
if (name === 'manifest')
|
|
1921
1949
|
continue;
|
|
1922
|
-
if (value instanceof File)
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
});
|
|
1950
|
+
if (!(value instanceof File))
|
|
1951
|
+
continue;
|
|
1952
|
+
if (name.startsWith(ASSET_PART_PREFIX)) {
|
|
1953
|
+
assetParts.set(name.slice(ASSET_PART_PREFIX.length), value);
|
|
1954
|
+
continue;
|
|
1928
1955
|
}
|
|
1956
|
+
modules.push({
|
|
1957
|
+
name,
|
|
1958
|
+
content: new Uint8Array(await value.arrayBuffer()),
|
|
1959
|
+
contentType: value.type || 'application/javascript+module',
|
|
1960
|
+
});
|
|
1929
1961
|
}
|
|
1930
1962
|
if (!modules.some((m) => m.name === manifest.entry)) {
|
|
1931
1963
|
return c.json({ error: `entry module '${manifest.entry}' is not among the uploaded files` }, 400);
|
|
1932
1964
|
}
|
|
1965
|
+
// The static-asset half of the §4 sandbox contract (self-serve-deploy.md §4.1). The
|
|
1966
|
+
// bytes are inert — no code, no authority — so they are ACCEPTED; the content-address
|
|
1967
|
+
// is not, so it is VERIFIED. The runtime's asset store dedups by hash across the whole
|
|
1968
|
+
// dispatch namespace, which means bytes stored under a hash they do not have would let
|
|
1969
|
+
// one push decide what another vertical's identical-hash asset serves. Re-deriving the
|
|
1970
|
+
// hash here (from the received bytes, with the same `assetHash` the CLI used) is what
|
|
1971
|
+
// makes that structurally impossible — and it is the only inspection of the bytes we do.
|
|
1972
|
+
const assets = [];
|
|
1973
|
+
for (const file of manifest.assets?.files ?? []) {
|
|
1974
|
+
const part = assetParts.get(file.path);
|
|
1975
|
+
if (!part) {
|
|
1976
|
+
return c.json({ error: `asset '${file.path}' is named in the manifest but was not uploaded` }, 400);
|
|
1977
|
+
}
|
|
1978
|
+
const content = new Uint8Array(await part.arrayBuffer());
|
|
1979
|
+
if (content.byteLength !== file.size) {
|
|
1980
|
+
return c.json({ error: `asset '${file.path}' declares ${file.size} bytes but ${content.byteLength} arrived` }, 400);
|
|
1981
|
+
}
|
|
1982
|
+
const actual = await assetHash(content, file.path);
|
|
1983
|
+
if (actual !== file.hash) {
|
|
1984
|
+
return c.json({
|
|
1985
|
+
error: `asset '${file.path}' does not match its declared content hash ` +
|
|
1986
|
+
`(declared ${file.hash}, computed ${actual}) — the hash is the runtime's shared dedup key, ` +
|
|
1987
|
+
`so a mismatch is refused rather than stored`,
|
|
1988
|
+
}, 400);
|
|
1989
|
+
}
|
|
1990
|
+
assets.push({
|
|
1991
|
+
path: file.path,
|
|
1992
|
+
hash: file.hash,
|
|
1993
|
+
size: file.size,
|
|
1994
|
+
contentType: file.contentType,
|
|
1995
|
+
content,
|
|
1996
|
+
});
|
|
1997
|
+
assetParts.delete(file.path);
|
|
1998
|
+
}
|
|
1999
|
+
if (assetParts.size > 0) {
|
|
2000
|
+
// An unlisted asset part would be uploaded by nothing and served by nothing; saying so
|
|
2001
|
+
// beats silently dropping it, because the builder's page would 404 with no explanation.
|
|
2002
|
+
const extra = [...assetParts.keys()].slice(0, 5).join(', ');
|
|
2003
|
+
return c.json({
|
|
2004
|
+
error: `${assetParts.size} uploaded asset part(s) are absent from the manifest (${extra}${assetParts.size > 5 ? ', …' : ''})`,
|
|
2005
|
+
}, 400);
|
|
2006
|
+
}
|
|
1933
2007
|
// Mint the version id first: the deploymentRef (the dispatch script name) is keyed
|
|
1934
2008
|
// on it, so it is CF-valid and unique per version.
|
|
1935
2009
|
const id = ulid();
|
|
@@ -1942,6 +2016,10 @@ export function createControlPlaneApi(options) {
|
|
|
1942
2016
|
modules,
|
|
1943
2017
|
doClasses: manifest.doClasses,
|
|
1944
2018
|
bindings: manifest.bindings,
|
|
2019
|
+
// #340: the verified bytes go up with the bundle. The manifest's routing config
|
|
2020
|
+
// rides along untouched — it decides what the RUNTIME does with paths, and carries
|
|
2021
|
+
// no reach, so there is nothing in it for the sandbox contract to refuse.
|
|
2022
|
+
...(manifest.assets ? { assets: { ...manifest.assets, files: assets } } : {}),
|
|
1945
2023
|
});
|
|
1946
2024
|
}
|
|
1947
2025
|
catch (e) {
|
|
@@ -2041,16 +2119,20 @@ export function createControlPlaneApi(options) {
|
|
|
2041
2119
|
if (!options.observability) {
|
|
2042
2120
|
return c.json({ error: 'observability is not configured on this control plane' }, 501);
|
|
2043
2121
|
}
|
|
2122
|
+
// `service` repeats: one deployed unit per param, so a caller can ask for a
|
|
2123
|
+
// vertical's whole set (the dashboard's "all versions") in one query and get one
|
|
2124
|
+
// merged stream back. Capped because each extra service is another backend query.
|
|
2125
|
+
const services = (c.req.queries('service') ?? []).filter((s) => s.length > 0);
|
|
2044
2126
|
const input = z
|
|
2045
2127
|
.object({
|
|
2046
|
-
|
|
2128
|
+
services: z.array(z.string().min(1).max(200)).max(20).optional(),
|
|
2047
2129
|
level: z.enum(['log', 'info', 'warn', 'error', 'debug']).optional(),
|
|
2048
2130
|
search: z.string().min(1).max(200).optional(),
|
|
2049
2131
|
hours: z.coerce.number().int().min(1).max(72).default(1),
|
|
2050
2132
|
limit: z.coerce.number().int().min(1).max(500).default(100),
|
|
2051
2133
|
})
|
|
2052
2134
|
.parse({
|
|
2053
|
-
|
|
2135
|
+
services: services.length ? services : undefined,
|
|
2054
2136
|
level: c.req.query('level') || undefined,
|
|
2055
2137
|
search: c.req.query('search') || undefined,
|
|
2056
2138
|
hours: c.req.query('hours'),
|
|
@@ -2265,6 +2347,16 @@ export function createControlPlaneApi(options) {
|
|
|
2265
2347
|
* label is unique within a tenant, so dropping the prefix stays collision-free. Both
|
|
2266
2348
|
* create (provision + reuse-match) and delete (reap-match) run through here, so they agree. */
|
|
2267
2349
|
const previewSlug = (slug, tag) => `${slug.split('/').at(-1)}--${tag}`;
|
|
2350
|
+
/** Reap one preview: wipe the DO in its own deployment, then drop the directory row and
|
|
2351
|
+
* its hostnames. Storage-before-row, the same ordering the DELETE route uses — a crash
|
|
2352
|
+
* between the two converges on retry. Shared by that route and by the create path, which
|
|
2353
|
+
* reaps a HALF-BUILT leftover before re-forking (see `orchestratedPreview`). */
|
|
2354
|
+
const reapPreview = async (c, preview) => {
|
|
2355
|
+
const vertical = await verticalForScope(c, preview);
|
|
2356
|
+
if (vertical)
|
|
2357
|
+
await vertical.deleteScope({ scopeId: preview.id });
|
|
2358
|
+
await options.host.deleteSnapshot(c.get('actor'), preview.tenantId, preview.id);
|
|
2359
|
+
};
|
|
2268
2360
|
/** Given a base hostname `<label>.<domain>`, mint (or find) the preview's `--<tag>`
|
|
2269
2361
|
* hostname `<label>--<tag>.<domain>` bound to `previewId`. Non-canonical, so it never
|
|
2270
2362
|
* demotes the prod surface. Shared by the fork and clean-room paths. */
|
|
@@ -2302,13 +2394,22 @@ export function createControlPlaneApi(options) {
|
|
|
2302
2394
|
}
|
|
2303
2395
|
return src.hostname;
|
|
2304
2396
|
}
|
|
2305
|
-
|
|
2306
|
-
if (!base) {
|
|
2397
|
+
if (platformBaseDomains.length === 0) {
|
|
2307
2398
|
throw new ControlPlaneError(409, `no platform base domain configured — a clean-room preview has no source URL to derive from`);
|
|
2308
2399
|
}
|
|
2400
|
+
// Mint under `<label>.<jurisdiction>.<baseDomain>`, exactly as provisioning does
|
|
2401
|
+
// (provision.ts `bindDefaultHostname` → `egeryds.global.substrat.run`). A clean-room
|
|
2402
|
+
// preview scope is provisioned `global` by construction (see the caller), and the
|
|
2403
|
+
// wildcard DNS/cert lives on `*.global.substrat.run` — NOT the certless apex
|
|
2404
|
+
// `*.substrat.run`. `platformBaseDomains` lists every platform suffix for custom-hostname
|
|
2405
|
+
// detection (`substrat.run`, `global.substrat.run`, …); the registrable base is the
|
|
2406
|
+
// shortest, the one all jurisdiction domains are subdomains of. Taking `[0]` grabbed the
|
|
2407
|
+
// bare apex and stranded clean-room previews on a hostname that never resolves.
|
|
2408
|
+
const baseDomain = [...platformBaseDomains].sort((a, b) => a.length - b.length)[0];
|
|
2409
|
+
const jurisdiction = 'global';
|
|
2309
2410
|
const tenant = await admin.getTenant(actor, tenantId);
|
|
2310
2411
|
const handle = tenant?.slug ?? tenantId;
|
|
2311
|
-
return `${slug.split('/').at(-1)}-${handle}.${
|
|
2412
|
+
return `${slug.split('/').at(-1)}-${handle}.${jurisdiction}.${baseDomain}`;
|
|
2312
2413
|
};
|
|
2313
2414
|
const orchestratedPreview = async (c, tenantId, slug,
|
|
2314
2415
|
// A FORK copies this scope's data; `null` provisions an empty clean-room scope (#509 (b)).
|
|
@@ -2361,7 +2462,20 @@ export function createControlPlaneApi(options) {
|
|
|
2361
2462
|
// onto the SAME preview — successive pushes roll their migrations forward on one copy
|
|
2362
2463
|
// (§4's rehearsal case) — unless `refresh` asks for a fresh one.
|
|
2363
2464
|
const existing = (await admin.listScopes(actor, { tenantId, vertical: slug })).find((s) => s.kind === 'preview' && s.slug === previewSlug(slug, opts.tag));
|
|
2364
|
-
|
|
2465
|
+
// A preview only HAS data once its two-phase create finished: the directory row lands
|
|
2466
|
+
// first as `provisioning` (K-31), the fork's export→restore runs, and `activateScope`
|
|
2467
|
+
// is the last step. So a row still at `provisioning` is a create that DIED mid-fork —
|
|
2468
|
+
// its DO is empty. Reuse must never adopt one: reuse only rebinds the version and the
|
|
2469
|
+
// hostname, it never copies data, so adopting a half-built row hands back a
|
|
2470
|
+
// permanently EMPTY preview and reports `reused: true` — success for a URL that shows
|
|
2471
|
+
// a reviewer no data at all. That is exactly what a CI retry does (the generated
|
|
2472
|
+
// workflow retries `preview create` on a transient), so the failure mode is the
|
|
2473
|
+
// COMMON one, not a corner: attempt 1 forks and dies, attempt 2 adopts its corpse and
|
|
2474
|
+
// goes green. Instead, reap the leftover and fall through to a fresh fork below —
|
|
2475
|
+
// which is what the retry was asking for. Same for an explicit `refresh`, whose fresh
|
|
2476
|
+
// scope would otherwise collide with the old row's still-bound `--<tag>` hostname.
|
|
2477
|
+
const stale = existing !== undefined && (opts.refresh || existing.status !== 'active');
|
|
2478
|
+
if (existing && !stale) {
|
|
2365
2479
|
// Heal a preview provisioned before #527: clear any inherited serving_ref so routing
|
|
2366
2480
|
// follows the bound version (its per-version script), not the prod serving script.
|
|
2367
2481
|
if (existing.servingRef)
|
|
@@ -2373,6 +2487,10 @@ export function createControlPlaneApi(options) {
|
|
|
2373
2487
|
await assertServesBoundVersion(existing.id);
|
|
2374
2488
|
return { scopeId: existing.id, hostname, url: `https://${hostname}`, versionId: opts.versionId, reused: true };
|
|
2375
2489
|
}
|
|
2490
|
+
// Free the tag: the slug is unique per tenant and the `--<tag>` hostname is still bound
|
|
2491
|
+
// to the old row, so the fresh fork below cannot be provisioned until this one is gone.
|
|
2492
|
+
if (existing && stale)
|
|
2493
|
+
await reapPreview(c, existing);
|
|
2376
2494
|
const previewId = scopeIdSchema.parse(ulid());
|
|
2377
2495
|
if (source) {
|
|
2378
2496
|
// A fresh fork. Export from where the prod data lives TODAY. The canonical
|
|
@@ -2531,10 +2649,7 @@ export function createControlPlaneApi(options) {
|
|
|
2531
2649
|
// Storage-before-row, the same ordering as the fork hard-delete: wipe the DO in the
|
|
2532
2650
|
// PR version's deployment, then deleteSnapshot (fork-only re-check, hostnames + row,
|
|
2533
2651
|
// audit). A crash between the two converges on retry.
|
|
2534
|
-
|
|
2535
|
-
if (vertical)
|
|
2536
|
-
await vertical.deleteScope({ scopeId: preview.id });
|
|
2537
|
-
await options.host.deleteSnapshot(actor, tenantId, preview.id);
|
|
2652
|
+
await reapPreview(c, preview);
|
|
2538
2653
|
return c.json({ deleted: preview.id });
|
|
2539
2654
|
}
|
|
2540
2655
|
catch (e) {
|