@feng0531/dsh-vcs 0.4.9 → 0.5.1
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/README.md +8 -5
- package/dist/client.js +3 -3
- package/dist/editor.js +59 -59
- package/dist/frontend.json +1 -1
- package/dist/interface.js +3 -3
- package/dist/svn-host.mjs +1 -1
- package/docs/RELEASING.md +41 -9
- package/git-history.mjs +6 -1
- package/index.mjs +313 -120
- package/local-command.mjs +58 -25
- package/package.json +11 -3
- package/src/commit-signature.mjs +26 -13
- package/src/historical-access.mjs +139 -0
- package/src/operation-budget.mjs +44 -0
- package/src/preview-capabilities.mjs +19 -0
- package/vcs.mjs +225 -167
- package/workspace-large-text.mjs +4 -3
package/index.mjs
CHANGED
|
@@ -3,8 +3,10 @@ import {readFileSync} from 'node:fs';
|
|
|
3
3
|
import {registerFrontendAssets} from './frontend-assets.mjs';
|
|
4
4
|
const runningFrontend=JSON.parse(readFileSync(new URL('./dist/frontend.json',import.meta.url),'utf8'));
|
|
5
5
|
import * as adapter from './vcs.mjs';
|
|
6
|
+
import {withOperationBudget} from './src/operation-budget.mjs';
|
|
6
7
|
import {runtimeInfo,checkUpdate} from './dist/updates.mjs';
|
|
7
8
|
import {createSvnHttpsHost} from './dist/svn-host.mjs';
|
|
9
|
+
import {historySearchArgs,validateDateBound,isObjectId,isChangeId} from './git-history.mjs';
|
|
8
10
|
|
|
9
11
|
export const name = 'dsh-vcs';
|
|
10
12
|
export const inject = ['connection', 'webServer', 'sessions', 'sessionPersistence'];
|
|
@@ -27,8 +29,6 @@ export async function resolveSessionCwd(ctx, sessionId, signal) {
|
|
|
27
29
|
|
|
28
30
|
const rediscover = () => Object.assign(new Error('Repository authorization is missing, expired, or changed. Rediscover repositories for this Session and retry.'), { code: 'vcs/rediscover-required' });
|
|
29
31
|
|
|
30
|
-
import {historySearchArgs,validateDateBound} from './git-history.mjs';
|
|
31
|
-
|
|
32
32
|
// Exact request fields are a protocol contract, independent of dispatch.
|
|
33
33
|
const REQUEST_FIELDS = {
|
|
34
34
|
'vcs/repositories': ['sessionId', 'subdirectory'],
|
|
@@ -48,57 +48,165 @@ const REQUEST_FIELDS = {
|
|
|
48
48
|
'vcs/revision-compare': ['sessionId', 'repositoryId', 'base', 'target', 'id', 'large'],
|
|
49
49
|
'vcs/revision-image': ['sessionId', 'repositoryId', 'base', 'target', 'id', 'side'],
|
|
50
50
|
'vcs/tree': ['sessionId', 'repositoryId', 'commit'],
|
|
51
|
+
'vcs/tree-page': ['sessionId', 'repositoryId', 'commit', 'path', 'offset', 'limit', 'query'],
|
|
51
52
|
'vcs/tree-file': ['sessionId', 'repositoryId', 'commit', 'path'],
|
|
52
53
|
'vcs/tree-segment': ['sessionId', 'repositoryId', 'commit', 'path', 'offset'],
|
|
53
54
|
'vcs/tree-history': ['sessionId', 'repositoryId', 'commit', 'path', 'offset', 'limit', 'follow', 'since', 'until'],
|
|
54
55
|
'vcs/commit-image': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id', 'side'],
|
|
55
56
|
'vcs/workspace-image': ['sessionId', 'repositoryId', 'mode', 'id', 'side'],
|
|
56
57
|
};
|
|
58
|
+
|
|
59
|
+
/** The supported endpoint list and each field whitelist derive from REQUEST_FIELDS. */
|
|
60
|
+
const ACTION_ENDPOINTS = new Set(Object.keys(REQUEST_FIELDS));
|
|
61
|
+
const REQUEST_FIELD_SETS = new Map(Object.entries(REQUEST_FIELDS).map(([endpoint, fields]) => [endpoint, new Set(fields)]));
|
|
62
|
+
const UPDATE_ENDPOINTS = new Set(['vcs/runtime-info', 'vcs/check-update']);
|
|
63
|
+
|
|
64
|
+
// Only repositories, SVN identity, workspace status and workspace comparison can
|
|
65
|
+
// address SVN working copies; every other action reads immutable Git history.
|
|
66
|
+
const SVN_CAPABLE_ENDPOINTS = new Set(['vcs/repositories', 'vcs/svn-identity', 'vcs/status', 'vcs/compare']);
|
|
67
|
+
const GIT_ONLY_ENDPOINTS = new Set([...ACTION_ENDPOINTS].filter(endpoint => !SVN_CAPABLE_ENDPOINTS.has(endpoint)));
|
|
68
|
+
|
|
69
|
+
// Named endpoint groups keep every request-shape and capacity decision in one place.
|
|
70
|
+
const HISTORICAL_PATH_ENDPOINTS = new Set(['vcs/tree-file', 'vcs/tree-segment', 'vcs/tree-history']);
|
|
71
|
+
const REVISION_PAIR_ENDPOINTS = new Set(['vcs/revision-changes', 'vcs/revision-compare', 'vcs/revision-image']);
|
|
72
|
+
const REVISION_CHANGE_ID_ENDPOINTS = new Set(['vcs/revision-compare', 'vcs/revision-image']);
|
|
73
|
+
const SELECTED_CHANGE_ENDPOINTS = new Set(['vcs/commit-compare', 'vcs/file-history', 'vcs/blame', 'vcs/commit-image']);
|
|
74
|
+
const COMMIT_ENDPOINTS = new Set(['vcs/commit', 'vcs/commit-compare', 'vcs/file-history', 'vcs/blame', 'vcs/tree', 'vcs/tree-page', 'vcs/tree-file', 'vcs/tree-segment', 'vcs/tree-history', 'vcs/commit-image']);
|
|
75
|
+
const LARGE_COMPARISON_ENDPOINTS = new Set(['vcs/compare', 'vcs/commit-compare', 'vcs/revision-compare']);
|
|
76
|
+
const HISTORY_PATH_ENDPOINTS = new Set(['vcs/file-history', 'vcs/tree-history']);
|
|
77
|
+
const PAGINATED_HISTORY_ENDPOINTS = new Set(['vcs/history', 'vcs/file-history', 'vcs/tree-history']);
|
|
78
|
+
const IMAGE_ENDPOINTS = new Set(['vcs/commit-image', 'vcs/revision-image', 'vcs/workspace-image']);
|
|
79
|
+
const WORKSPACE_CHANGE_ENDPOINTS = new Set(['vcs/workspace-image', 'vcs/conflict-stages']);
|
|
80
|
+
const CHANGE_ID_COMPARISON_ENDPOINTS = new Set(['vcs/compare', 'vcs/workspace-image']);
|
|
81
|
+
const IMAGE_SIDES = new Set(['left', 'right']);
|
|
82
|
+
const COMPARISON_MODES = new Set(['all', 'unstaged', 'staged']);
|
|
83
|
+
|
|
84
|
+
/** Repositories requests may only narrow discovery to a relative Session subdirectory. */
|
|
85
|
+
function validateRepositoriesPayload(payload) {
|
|
86
|
+
if (payload.subdirectory === undefined) return;
|
|
87
|
+
const dir = payload.subdirectory;
|
|
88
|
+
if (typeof dir !== 'string' || !dir || dir.length > 4096 || /[\0:]/.test(dir) || /^[\/\\]/.test(dir) || dir.split(/[\/\\]/).includes('..')) throw invalid('subdirectory must be a relative path within the Session directory.');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Historical paths are repository-relative and can never be absolute or escape. */
|
|
92
|
+
function validateHistoricalPath(endpoint, path) {
|
|
93
|
+
if (endpoint === 'vcs/tree-page' && (path === undefined || path === '')) return;
|
|
94
|
+
if (endpoint !== 'vcs/tree-page' && !HISTORICAL_PATH_ENDPOINTS.has(endpoint)) return;
|
|
95
|
+
if (typeof path !== 'string' || !path || path.length > 32768 || path.includes('\0') || path.startsWith('/') || path.split('/').some(part => !part || part === '.' || part === '..')) throw invalid('Invalid historical file path.');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validateImageSide(endpoint, side) {
|
|
99
|
+
if (IMAGE_ENDPOINTS.has(endpoint) && side !== undefined && !IMAGE_SIDES.has(side)) throw invalid('Invalid image side.');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validateRevisionSelection(endpoint, payload) {
|
|
103
|
+
if (!REVISION_PAIR_ENDPOINTS.has(endpoint)) return;
|
|
104
|
+
for (const oid of [payload.base, payload.target]) if (typeof oid !== 'string' || !isObjectId(oid)) throw invalid('Invalid revision commit id.');
|
|
105
|
+
if (REVISION_CHANGE_ID_ENDPOINTS.has(endpoint) && (typeof payload.id !== 'string' || !isChangeId(payload.id))) throw invalid('Invalid revision change id.');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validateSelectedChangeId(endpoint, payload) {
|
|
109
|
+
if (SELECTED_CHANGE_ENDPOINTS.has(endpoint) && (typeof payload.id !== 'string' || !isChangeId(payload.id))) throw invalid('Invalid historical change id.');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function validateCommitSelection(endpoint, payload) {
|
|
113
|
+
if (!COMMIT_ENDPOINTS.has(endpoint)) return;
|
|
114
|
+
if (typeof payload.commit !== 'string' || !isObjectId(payload.commit)) throw invalid('Invalid commit id.');
|
|
115
|
+
if (payload.parentIndex !== undefined && (!Number.isInteger(payload.parentIndex) || payload.parentIndex < 0 || payload.parentIndex > 100)) throw invalid('Invalid parent index.');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function validateLargeComparison(endpoint, payload) {
|
|
119
|
+
if (LARGE_COMPARISON_ENDPOINTS.has(endpoint) && payload.large !== undefined && typeof payload.large !== 'boolean') throw invalid('Invalid large comparison mode.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validateBlameWindow(endpoint, payload) {
|
|
123
|
+
if (endpoint !== 'vcs/blame') return;
|
|
124
|
+
const startInvalid = payload.startLine !== undefined && (!Number.isInteger(payload.startLine) || payload.startLine < 1 || payload.startLine > 100001);
|
|
125
|
+
const limitInvalid = payload.lineLimit !== undefined && (!Number.isInteger(payload.lineLimit) || payload.lineLimit < 1 || payload.lineLimit > 500);
|
|
126
|
+
if (startInvalid || limitInvalid) throw invalid('Invalid blame line window.');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function validateContentSearch(endpoint, payload) {
|
|
130
|
+
if (endpoint !== 'vcs/grep') return;
|
|
131
|
+
if (typeof payload.query !== 'string' || !payload.query || payload.query.length > 1024 || /[\0\r\n]/.test(payload.query)) throw invalid('Invalid content search query.');
|
|
132
|
+
if (payload.commit !== undefined && (typeof payload.commit !== 'string' || !isObjectId(payload.commit))) throw invalid('Invalid content search commit.');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function validateRenameFollow(endpoint, payload) {
|
|
136
|
+
if (HISTORY_PATH_ENDPOINTS.has(endpoint) && payload.follow !== undefined && typeof payload.follow !== 'boolean') throw invalid('Invalid rename follow mode.');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function validateHistoryDates(endpoint, payload) {
|
|
140
|
+
if (!HISTORY_PATH_ENDPOINTS.has(endpoint) || (payload.since === undefined && payload.until === undefined)) return;
|
|
141
|
+
try {
|
|
142
|
+
if (payload.since !== undefined) validateDateBound(payload.since);
|
|
143
|
+
if (payload.until !== undefined) validateDateBound(payload.until);
|
|
144
|
+
} catch { throw invalid('Invalid history date bound.'); }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function validateHistoryPagination(endpoint, payload) {
|
|
148
|
+
if (!PAGINATED_HISTORY_ENDPOINTS.has(endpoint)) return;
|
|
149
|
+
if (payload.offset === null || payload.limit === null) throw invalid('Invalid history pagination.');
|
|
150
|
+
if (payload.snapshot !== undefined && (typeof payload.snapshot !== 'string' || !isObjectId(payload.snapshot))) throw invalid('Invalid history snapshot.');
|
|
151
|
+
const offset = payload.offset ?? 0, limit = payload.limit ?? 50;
|
|
152
|
+
if (!Number.isInteger(offset) || offset < 0 || offset > 10000 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw invalid('Invalid history pagination.');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function validateWorkspaceChangeId(endpoint, payload) {
|
|
156
|
+
if (WORKSPACE_CHANGE_ENDPOINTS.has(endpoint) && (typeof payload.id !== 'string' || !isChangeId(payload.id))) throw invalid('Invalid workspace image change id.');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function validateModeAndComparisonId(endpoint, payload) {
|
|
160
|
+
if (!COMPARISON_MODES.has(payload.mode ?? 'all')) throw invalid('Unsupported comparison mode.');
|
|
161
|
+
if (CHANGE_ID_COMPARISON_ENDPOINTS.has(endpoint) && (typeof payload.id !== 'string' || !payload.id || payload.id.length > 32768)) throw invalid('A change id is required.');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The validation order below is part of the protocol: the first failing group wins. */
|
|
165
|
+
function validateRepositoryPayload(endpoint, payload) {
|
|
166
|
+
if (typeof payload.repositoryId !== 'string' || !payload.repositoryId || payload.repositoryId.length > 1024) throw invalid('A discovered repositoryId is required.');
|
|
167
|
+
validateHistoricalPath(endpoint, payload.path);
|
|
168
|
+
validateImageSide(endpoint, payload.side);
|
|
169
|
+
validateRevisionSelection(endpoint, payload);
|
|
170
|
+
validateSelectedChangeId(endpoint, payload);
|
|
171
|
+
validateCommitSelection(endpoint, payload);
|
|
172
|
+
validateLargeComparison(endpoint, payload);
|
|
173
|
+
if (endpoint === 'vcs/tree-segment' && payload.offset !== undefined && (!Number.isInteger(payload.offset) || payload.offset < 0 || payload.offset > 16777216)) throw invalid('Invalid segment offset.');
|
|
174
|
+
validateBlameWindow(endpoint, payload);
|
|
175
|
+
validateContentSearch(endpoint, payload);
|
|
176
|
+
validateRenameFollow(endpoint, payload);
|
|
177
|
+
validateHistoryDates(endpoint, payload);
|
|
178
|
+
if (endpoint === 'vcs/history') { try { historySearchArgs(payload.search); } catch { throw invalid('Invalid history search.'); } }
|
|
179
|
+
validateHistoryPagination(endpoint, payload);
|
|
180
|
+
if (endpoint === 'vcs/tree-page') {
|
|
181
|
+
const {offset = 0, limit = 100} = payload;
|
|
182
|
+
if (payload.query !== undefined && (typeof payload.query !== 'string' || payload.query.length > 1024 || /[\0\r\n]/.test(payload.query))) throw invalid('Invalid tree query.');
|
|
183
|
+
if (!Number.isInteger(offset) || offset < 0 || offset > 100000 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw invalid('Invalid tree pagination.');
|
|
184
|
+
}
|
|
185
|
+
validateWorkspaceChangeId(endpoint, payload);
|
|
186
|
+
validateModeAndComparisonId(endpoint, payload);
|
|
187
|
+
}
|
|
188
|
+
|
|
57
189
|
function validatePayload(endpoint, payload) {
|
|
58
190
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw invalid('An object payload is required.');
|
|
59
|
-
const
|
|
60
|
-
if (Object.keys(payload).some(key => !
|
|
61
|
-
if (endpoint === 'vcs/repositories')
|
|
62
|
-
|
|
63
|
-
const dir = payload.subdirectory;
|
|
64
|
-
if (typeof dir !== 'string' || !dir || dir.length > 4096 || /[\0:]/.test(dir) || /^[\/\\]/.test(dir) || dir.split(/[\/\\]/).includes('..')) throw invalid('subdirectory must be a relative path within the Session directory.');
|
|
65
|
-
}
|
|
66
|
-
} else {
|
|
67
|
-
if (typeof payload.repositoryId !== 'string' || !payload.repositoryId || payload.repositoryId.length > 1024) throw invalid('A discovered repositoryId is required.');
|
|
68
|
-
if(['vcs/tree-file','vcs/tree-segment','vcs/tree-history'].includes(endpoint)&&(typeof payload.path!=='string'||!payload.path||payload.path.length>32768||payload.path.includes('\0')||payload.path.startsWith('/')||payload.path.split('/').some(part=>!part||part==='.'||part==='..')))throw invalid('Invalid historical file path.');
|
|
69
|
-
if(['vcs/commit-image','vcs/revision-image','vcs/workspace-image'].includes(endpoint)&&payload.side!==undefined&&!['left','right'].includes(payload.side))throw invalid('Invalid image side.');
|
|
70
|
-
if(['vcs/revision-changes','vcs/revision-compare','vcs/revision-image'].includes(endpoint)){
|
|
71
|
-
for(const oid of [payload.base,payload.target])if(typeof oid!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(oid))throw invalid('Invalid revision commit id.');
|
|
72
|
-
if(['vcs/revision-compare','vcs/revision-image'].includes(endpoint)&&(typeof payload.id!=='string'||!/^[a-f0-9]{64}$/.test(payload.id)))throw invalid('Invalid revision change id.');
|
|
73
|
-
}
|
|
74
|
-
if(['vcs/commit-compare','vcs/file-history','vcs/blame','vcs/commit-image'].includes(endpoint)&&(typeof payload.id!=='string'||!/^[a-f0-9]{64}$/.test(payload.id)))throw invalid('Invalid historical change id.');
|
|
75
|
-
if(['vcs/commit','vcs/commit-compare','vcs/file-history','vcs/blame','vcs/tree','vcs/tree-file','vcs/tree-segment','vcs/tree-history','vcs/commit-image'].includes(endpoint)){
|
|
76
|
-
if(typeof payload.commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.commit))throw invalid('Invalid commit id.');
|
|
77
|
-
if(payload.parentIndex!==undefined&&(!Number.isInteger(payload.parentIndex)||payload.parentIndex<0||payload.parentIndex>100))throw invalid('Invalid parent index.');
|
|
78
|
-
}
|
|
79
|
-
if(['vcs/compare','vcs/commit-compare','vcs/revision-compare'].includes(endpoint)&&payload.large!==undefined&&typeof payload.large!=='boolean')throw invalid('Invalid large comparison mode.');
|
|
80
|
-
if(endpoint==='vcs/tree-segment'&&payload.offset!==undefined&&(!Number.isInteger(payload.offset)||payload.offset<0||payload.offset>16777216))throw invalid('Invalid segment offset.');
|
|
81
|
-
if(endpoint==='vcs/blame'&&((payload.startLine!==undefined&&(!Number.isInteger(payload.startLine)||payload.startLine<1||payload.startLine>100001))||(payload.lineLimit!==undefined&&(!Number.isInteger(payload.lineLimit)||payload.lineLimit<1||payload.lineLimit>500))))throw invalid('Invalid blame line window.');
|
|
82
|
-
if(endpoint==='vcs/grep'){if(typeof payload.query!=='string'||!payload.query||payload.query.length>1024||/[\0\r\n]/.test(payload.query))throw invalid('Invalid content search query.');if(payload.commit!==undefined&&(typeof payload.commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.commit)))throw invalid('Invalid content search commit.');}
|
|
83
|
-
if(['vcs/file-history','vcs/tree-history'].includes(endpoint)&&payload.follow!==undefined&&typeof payload.follow!=='boolean')throw invalid('Invalid rename follow mode.');
|
|
84
|
-
if(['vcs/file-history','vcs/tree-history'].includes(endpoint)&&(payload.since!==undefined||payload.until!==undefined)){try{if(payload.since!==undefined)validateDateBound(payload.since);if(payload.until!==undefined)validateDateBound(payload.until);}catch{throw invalid('Invalid history date bound.');}}
|
|
85
|
-
if(endpoint==='vcs/history'){try{historySearchArgs(payload.search);}catch{throw invalid('Invalid history search.');}}
|
|
86
|
-
if(['vcs/history','vcs/file-history','vcs/tree-history'].includes(endpoint)){
|
|
87
|
-
if(payload.offset===null||payload.limit===null)throw invalid('Invalid history pagination.');
|
|
88
|
-
if(payload.snapshot!==undefined&&(typeof payload.snapshot!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.snapshot)))throw invalid('Invalid history snapshot.');
|
|
89
|
-
if(!Number.isInteger(payload.offset??0)||(payload.offset??0)<0||(payload.offset??0)>10000||!Number.isInteger(payload.limit??50)||(payload.limit??50)<1||(payload.limit??50)>100)throw invalid('Invalid history pagination.');
|
|
90
|
-
}
|
|
91
|
-
if(['vcs/workspace-image','vcs/conflict-stages'].includes(endpoint)&&(typeof payload.id!=='string'||!/^[a-f0-9]{64}$/.test(payload.id)))throw invalid('Invalid workspace image change id.');
|
|
92
|
-
if (!['all', 'unstaged', 'staged'].includes(payload.mode ?? 'all')) throw invalid('Unsupported comparison mode.');
|
|
93
|
-
if (['vcs/compare','vcs/workspace-image'].includes(endpoint) && (typeof payload.id !== 'string' || !payload.id || payload.id.length > 32768)) throw invalid('A change id is required.');
|
|
94
|
-
}
|
|
191
|
+
const allowedFields = REQUEST_FIELD_SETS.get(endpoint);
|
|
192
|
+
if (Object.keys(payload).some(key => !allowedFields.has(key))) throw invalid('Only Session-addressed repository requests are supported; unknown payload field.');
|
|
193
|
+
if (endpoint === 'vcs/repositories') validateRepositoriesPayload(payload);
|
|
194
|
+
else validateRepositoryPayload(endpoint, payload);
|
|
95
195
|
}
|
|
96
196
|
|
|
197
|
+
/** Forward an optional field only when the request actually supplied it. */
|
|
198
|
+
const optionalField = (key, value) => value === undefined ? {} : {[key]: value};
|
|
199
|
+
|
|
97
200
|
/** Dispatch only after grants and capacity have been checked by createHandler. */
|
|
98
201
|
async function readRepository(api, endpoint, repository, payload, {cwd, mode, signal}) {
|
|
99
202
|
switch (endpoint) {
|
|
203
|
+
case 'vcs/tree-page':
|
|
204
|
+
return api.getHistoricalTreePage({...repository}, {commit: payload.commit, path: payload.path ?? '', offset: payload.offset ?? 0, limit: payload.limit ?? 100, ...optionalField('query', payload.query), signal});
|
|
100
205
|
case 'vcs/tree-history':
|
|
101
|
-
return api.listPathHistory({...repository},
|
|
206
|
+
return api.listPathHistory({...repository}, {
|
|
207
|
+
commit: payload.commit, path: payload.path, offset: payload.offset ?? 0, limit: payload.limit ?? 50, follow: payload.follow ?? false,
|
|
208
|
+
...optionalField('since', payload.since), ...optionalField('until', payload.until), signal,
|
|
209
|
+
});
|
|
102
210
|
case 'vcs/tree-segment':
|
|
103
211
|
return api.getHistoricalSegment({...repository}, {commit: payload.commit, path: payload.path, offset: payload.offset ?? 0, signal});
|
|
104
212
|
case 'vcs/workspace-image':
|
|
@@ -118,19 +226,21 @@ async function readRepository(api, endpoint, repository, payload, {cwd, mode, si
|
|
|
118
226
|
case 'vcs/tree':
|
|
119
227
|
return api.getHistoricalTree({...repository}, {commit: payload.commit, signal});
|
|
120
228
|
case 'vcs/blame':
|
|
121
|
-
return api.getFileBlame({...repository}, {
|
|
122
|
-
|
|
123
|
-
...(payload.
|
|
229
|
+
return api.getFileBlame({...repository}, {
|
|
230
|
+
commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id,
|
|
231
|
+
...optionalField('startLine', payload.startLine), ...optionalField('lineLimit', payload.lineLimit), signal,
|
|
232
|
+
});
|
|
124
233
|
case 'vcs/file-history':
|
|
125
|
-
return api.listFileHistory({...repository}, {
|
|
126
|
-
offset: payload.offset ?? 0, limit: payload.limit ?? 50,
|
|
127
|
-
...(
|
|
234
|
+
return api.listFileHistory({...repository}, {
|
|
235
|
+
commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, offset: payload.offset ?? 0, limit: payload.limit ?? 50,
|
|
236
|
+
...optionalField('follow', payload.follow), ...optionalField('since', payload.since), ...optionalField('until', payload.until), signal,
|
|
237
|
+
});
|
|
128
238
|
case 'vcs/revision-changes':
|
|
129
239
|
return api.getRevisionChanges({...repository}, {base: payload.base, target: payload.target, signal});
|
|
130
240
|
case 'vcs/revision-compare':
|
|
131
|
-
return api.getRevisionComparison({...repository}, {base: payload.base, target: payload.target, id: payload.id, ...(
|
|
241
|
+
return api.getRevisionComparison({...repository}, {base: payload.base, target: payload.target, id: payload.id, ...optionalField('large', payload.large), signal});
|
|
132
242
|
case 'vcs/commit-compare':
|
|
133
|
-
return api.getCommitComparison({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, ...(
|
|
243
|
+
return api.getCommitComparison({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, ...optionalField('large', payload.large), signal});
|
|
134
244
|
case 'vcs/commit':
|
|
135
245
|
return api.getCommitDetails({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, signal});
|
|
136
246
|
case 'vcs/history':
|
|
@@ -138,106 +248,194 @@ async function readRepository(api, endpoint, repository, payload, {cwd, mode, si
|
|
|
138
248
|
case 'vcs/status':
|
|
139
249
|
return {cwd, repository: {...repository}, changes: await api.listChanges({...repository}, mode, {signal}), mode};
|
|
140
250
|
case 'vcs/conflict-stages':
|
|
141
|
-
return api.getConflictStages({...repository},{mode,id:payload.id,signal});
|
|
251
|
+
return api.getConflictStages({...repository}, {mode, id: payload.id, signal});
|
|
142
252
|
case 'vcs/compare':
|
|
143
|
-
return api.getComparison({...repository}, {mode, id: payload.id, ...(signal
|
|
253
|
+
return api.getComparison({...repository}, {mode, id: payload.id, ...optionalField('signal', signal), ...optionalField('large', payload.large)});
|
|
144
254
|
}
|
|
145
255
|
}
|
|
146
256
|
|
|
147
257
|
/** Limit active adapter operations and retain only bounded, short-lived discovery grants. */
|
|
148
|
-
export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.now, ttlMs = 5 * 60_000, maxSessions = 32, maxRepositories = 512, bindSvnIdentityResolver } = {}) {
|
|
258
|
+
export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.now, ttlMs = 5 * 60_000, maxSessions = 32, maxRepositories = 512, operationTimeoutMs = 15000, bindSvnIdentityResolver } = {}) {
|
|
149
259
|
let active = 0, activeImages = 0, activeSegments = 0;
|
|
150
260
|
const sessions = new Map();
|
|
261
|
+
|
|
262
|
+
const clearGrantCache = entry => {
|
|
263
|
+
for (const repository of entry?.repositories.values() ?? []) api.clearHistoricalCache?.(repository.root);
|
|
264
|
+
};
|
|
265
|
+
const revokeSession = id => { clearGrantCache(sessions.get(id)); sessions.delete(id); };
|
|
266
|
+
|
|
267
|
+
/** Resolve a live Session grant, evicting expired entries and cwd-mismatched grants. */
|
|
151
268
|
function current(sessionId, cwd) {
|
|
152
|
-
for (const [id, entry] of sessions) if (entry.expiresAt <= now())
|
|
269
|
+
for (const [id, entry] of sessions) if (entry.expiresAt <= now()) revokeSession(id);
|
|
153
270
|
const entry = sessions.get(sessionId);
|
|
154
|
-
if (entry && entry.cwd !== cwd) {
|
|
271
|
+
if (entry && entry.cwd !== cwd) { revokeSession(sessionId); return undefined; }
|
|
155
272
|
return entry;
|
|
156
273
|
}
|
|
274
|
+
|
|
275
|
+
/** Re-read the Session cwd, then require the same grant object to still be current. */
|
|
157
276
|
async function assertCurrent(sessionId, cwd, entry, signal) {
|
|
158
277
|
const latest = await resolveSessionCwd(ctx, sessionId, signal);
|
|
159
278
|
signal?.throwIfAborted();
|
|
160
279
|
if (current(sessionId, latest) !== entry || latest !== cwd) throw rediscover();
|
|
161
280
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
281
|
+
|
|
282
|
+
/** Adapter work finished: both the cwd grant and the selected repository must still be current. */
|
|
283
|
+
async function assertCurrentGrant(sessionId, cwd, entry, repositoryId, repository, signal) {
|
|
284
|
+
await assertCurrent(sessionId, cwd, entry, signal);
|
|
285
|
+
if (entry.repositories.get(repositoryId) !== repository) throw rediscover();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (bindSvnIdentityResolver !== undefined) {
|
|
289
|
+
if (typeof bindSvnIdentityResolver !== 'function') throw new Error('Invalid internal SVN resolver binding');
|
|
290
|
+
bindSvnIdentityResolver(async (address, signal) => {
|
|
291
|
+
validatePayload('vcs/svn-identity', address);
|
|
292
|
+
signal?.throwIfAborted();
|
|
293
|
+
const cwd = await resolveSessionCwd(ctx, address.sessionId, signal);
|
|
294
|
+
const entry = current(address.sessionId, cwd);
|
|
295
|
+
const repository = entry?.repositories.get(address.repositoryId);
|
|
296
|
+
if (!repository) throw rediscover();
|
|
297
|
+
if (repository.type !== 'svn') throw invalid('SVN working copy required.');
|
|
298
|
+
const identity = await api.getSvnIdentity({...repository}, {signal});
|
|
299
|
+
await assertCurrentGrant(address.sessionId, cwd, entry, address.repositoryId, repository, signal);
|
|
300
|
+
return {...identity, cwd};
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Update queries accept no fields and are admitted separately from repository reads. */
|
|
305
|
+
async function handleUpdateRequest(endpoint, payload, signal) {
|
|
306
|
+
try {
|
|
307
|
+
signal?.throwIfAborted();
|
|
308
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.keys(payload).length) throw invalid('Update requests accept no fields.');
|
|
309
|
+
if (endpoint === 'vcs/runtime-info') return {ok: true, value: runtimeInfo()};
|
|
310
|
+
if (active >= maxActive) return failure('vcs/busy', 'Too many VCS requests. Please retry.');
|
|
311
|
+
active++;
|
|
312
|
+
try { return {ok: true, value: await checkUpdate({signal})}; } finally { active--; }
|
|
313
|
+
} catch (error) {
|
|
314
|
+
return failure(signal?.aborted ? 'vcs/cancelled' : 'vcs/update-failed', error.message);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Discovery creates, replaces or merges grants; a stale scan can never resurrect one. */
|
|
319
|
+
async function discoverRepositories(payload, cwd, entry, signal) {
|
|
320
|
+
if (!entry) {
|
|
321
|
+
while (sessions.size >= maxSessions) revokeSession(sessions.keys().next().value);
|
|
322
|
+
entry = { cwd, repositories: new Map(), expiresAt: now() + ttlMs, scanSequence: 0, fullScanSequence: 0 };
|
|
323
|
+
sessions.set(payload.sessionId, entry);
|
|
324
|
+
}
|
|
325
|
+
const targeted = payload.subdirectory !== undefined;
|
|
326
|
+
const sequence = ++entry.scanSequence;
|
|
327
|
+
if (!targeted) entry.fullScanSequence = sequence;
|
|
328
|
+
const fullSequence = entry.fullScanSequence;
|
|
329
|
+
const result = await api.discoverRepositories(cwd, { signal, subdirectory: payload.subdirectory, maxDepth: 1, shallow: true });
|
|
330
|
+
// A stale scan must never recreate/replace a grant invalidated by a cwd change or eviction.
|
|
331
|
+
await assertCurrent(payload.sessionId, cwd, entry, signal);
|
|
332
|
+
// Full scans replace grants. Never let an older full scan erase a newer scan,
|
|
333
|
+
// or an older targeted scan resurrect grants after a new full scan begins.
|
|
334
|
+
if ((!targeted && sequence !== entry.scanSequence) || (targeted && fullSequence !== entry.fullScanSequence)) throw rediscover();
|
|
335
|
+
const grants = targeted ? new Map(entry.repositories) : new Map();
|
|
336
|
+
let truncated = !!result.truncated;
|
|
337
|
+
const warnings = [...result.warnings];
|
|
338
|
+
const repositories = [];
|
|
339
|
+
for (const repository of result.repositories) {
|
|
340
|
+
if (!grants.has(repository.id) && grants.size >= maxRepositories) { truncated = true; continue; }
|
|
341
|
+
grants.set(repository.id, { ...repository });
|
|
342
|
+
repositories.push({ ...repository });
|
|
343
|
+
}
|
|
344
|
+
if (repositories.length !== result.repositories.length) warnings.push('Repository authorization limit reached; use a new Session or narrow its working directory.');
|
|
345
|
+
clearGrantCache(entry);
|
|
346
|
+
entry.repositories = grants;
|
|
347
|
+
entry.expiresAt = now() + ttlMs;
|
|
348
|
+
return { cwd, repositories, truncated, warnings };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Read and revalidate SVN identity, returning only the fixed remote projection. */
|
|
352
|
+
async function readSvnIdentity(payload, cwd, entry, repository, signal) {
|
|
353
|
+
if (repository.type !== 'svn') throw invalid('SVN working copy required.');
|
|
354
|
+
const identity = await api.getSvnIdentity({...repository}, {signal});
|
|
355
|
+
await assertCurrentGrant(payload.sessionId, cwd, entry, payload.repositoryId, repository, signal);
|
|
356
|
+
return {
|
|
357
|
+
root: identity.root, uuid: identity.uuid, scope: identity.scope,
|
|
358
|
+
origin: identity.origin, revision: identity.revision, remoteEnabled: false,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Reserve the independent image or text-segment slot; report a busy failure instead of queuing. */
|
|
363
|
+
function admitHeavyOperation(endpoint, payload) {
|
|
364
|
+
let imageSlot = false, segmentSlot = false;
|
|
365
|
+
if (endpoint === 'vcs/tree-segment' || (LARGE_COMPARISON_ENDPOINTS.has(endpoint) && payload.large === true)) {
|
|
366
|
+
if (activeSegments >= 1) return {busy: failure('vcs/busy', 'A text segment is already loading. Please retry.'), imageSlot, segmentSlot};
|
|
367
|
+
activeSegments++; segmentSlot = true;
|
|
368
|
+
}
|
|
369
|
+
if (IMAGE_ENDPOINTS.has(endpoint)) {
|
|
370
|
+
if (activeImages >= 2) return {busy: failure('vcs/busy', 'Too many image requests. Please retry.'), imageSlot, segmentSlot};
|
|
371
|
+
activeImages++; imageSlot = true;
|
|
172
372
|
}
|
|
173
|
-
|
|
373
|
+
return {busy: null, imageSlot, segmentSlot};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Read through the adapter, then require both the cwd grant and the selected repository to be current. */
|
|
377
|
+
async function readGrantedRepository(endpoint, repository, payload, cwd, mode, entry, signal) {
|
|
378
|
+
const value = await readRepository(api, endpoint, repository, payload, {cwd, mode, signal});
|
|
379
|
+
await assertCurrentGrant(payload.sessionId, cwd, entry, payload.repositoryId, repository, signal);
|
|
380
|
+
return value;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const execute = async (endpoint, payload, signal) => {
|
|
384
|
+
if (UPDATE_ENDPOINTS.has(endpoint)) return handleUpdateRequest(endpoint, payload, signal);
|
|
385
|
+
if (!ACTION_ENDPOINTS.has(endpoint)) return failure('vcs/not-found', 'Unknown VCS endpoint.');
|
|
174
386
|
if (active >= maxActive) return failure('vcs/busy', 'Too many VCS requests. Please retry.');
|
|
175
387
|
active++;
|
|
176
|
-
let imageSlot=false,segmentSlot=false;
|
|
388
|
+
let imageSlot = false, segmentSlot = false;
|
|
177
389
|
try {
|
|
178
390
|
signal?.throwIfAborted();
|
|
179
391
|
validatePayload(endpoint, payload);
|
|
180
392
|
const cwd = await resolveSessionCwd(ctx, payload.sessionId, signal);
|
|
181
393
|
signal?.throwIfAborted();
|
|
182
|
-
|
|
183
|
-
if (endpoint === 'vcs/repositories') {
|
|
184
|
-
if (!entry) {
|
|
185
|
-
while (sessions.size >= maxSessions) sessions.delete(sessions.keys().next().value);
|
|
186
|
-
entry = { cwd, repositories: new Map(), expiresAt: now() + ttlMs, scanSequence: 0, fullScanSequence: 0 };
|
|
187
|
-
sessions.set(payload.sessionId, entry);
|
|
188
|
-
}
|
|
189
|
-
const targeted = payload.subdirectory !== undefined;
|
|
190
|
-
const sequence = ++entry.scanSequence;
|
|
191
|
-
if (!targeted) entry.fullScanSequence = sequence;
|
|
192
|
-
const fullSequence = entry.fullScanSequence;
|
|
193
|
-
const result = await api.discoverRepositories(cwd, { signal, subdirectory: payload.subdirectory, maxDepth: 1, shallow: true });
|
|
194
|
-
// A stale scan must never recreate/replace a grant invalidated by a cwd change or eviction.
|
|
195
|
-
await assertCurrent(payload.sessionId, cwd, entry, signal);
|
|
196
|
-
// Full scans replace grants. Never let an older full scan erase a newer scan,
|
|
197
|
-
// or an older targeted scan resurrect grants after a new full scan begins.
|
|
198
|
-
if ((!targeted && sequence !== entry.scanSequence) || (targeted && fullSequence !== entry.fullScanSequence)) throw rediscover();
|
|
199
|
-
const grants = targeted ? new Map(entry.repositories) : new Map();
|
|
200
|
-
let truncated = !!result.truncated;
|
|
201
|
-
const warnings = [...result.warnings];
|
|
202
|
-
const repositories = [];
|
|
203
|
-
for (const repository of result.repositories) {
|
|
204
|
-
if (!grants.has(repository.id) && grants.size >= maxRepositories) { truncated = true; continue; }
|
|
205
|
-
grants.set(repository.id, { ...repository });
|
|
206
|
-
repositories.push({ ...repository });
|
|
207
|
-
}
|
|
208
|
-
if (repositories.length !== result.repositories.length) warnings.push('Repository authorization limit reached; use a new Session or narrow its working directory.');
|
|
209
|
-
entry.repositories = grants;
|
|
210
|
-
entry.expiresAt = now() + ttlMs;
|
|
211
|
-
return { ok: true, value: { cwd, repositories, truncated, warnings } };
|
|
212
|
-
}
|
|
394
|
+
const entry = current(payload.sessionId, cwd);
|
|
395
|
+
if (endpoint === 'vcs/repositories') return { ok: true, value: await discoverRepositories(payload, cwd, entry, signal) };
|
|
213
396
|
const repository = entry?.repositories.get(payload.repositoryId);
|
|
214
397
|
if (!repository) throw rediscover();
|
|
215
|
-
if(endpoint==='vcs/svn-identity'){
|
|
216
|
-
if(repository.type!=='svn')throw invalid('SVN working copy required.');
|
|
217
|
-
const identity=await api.getSvnIdentity({...repository},{signal});await assertCurrent(payload.sessionId,cwd,entry,signal);if(entry.repositories.get(payload.repositoryId)!==repository)throw rediscover();
|
|
218
|
-
return {ok:true,value:{root:identity.root,uuid:identity.uuid,scope:identity.scope,origin:identity.origin,revision:identity.revision,remoteEnabled:false}};
|
|
219
|
-
}
|
|
398
|
+
if (endpoint === 'vcs/svn-identity') return { ok: true, value: await readSvnIdentity(payload, cwd, entry, repository, signal) };
|
|
220
399
|
const mode = payload.mode ?? 'all';
|
|
221
400
|
if (repository.type === 'svn' && mode !== 'all') throw invalid('SVN supports only all mode.');
|
|
222
401
|
// Both adapter operations revalidate the canonical root; comparison also validates
|
|
223
402
|
// the change ID against fresh status, so do not duplicate a full status scan here.
|
|
224
|
-
if(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return
|
|
403
|
+
if (GIT_ONLY_ENDPOINTS.has(endpoint) && repository.type !== 'git') throw invalid('History currently supports Git only.');
|
|
404
|
+
const admission = admitHeavyOperation(endpoint, payload);
|
|
405
|
+
imageSlot = admission.imageSlot;
|
|
406
|
+
segmentSlot = admission.segmentSlot;
|
|
407
|
+
if (admission.busy) return admission.busy;
|
|
408
|
+
return { ok: true, value: await readGrantedRepository(endpoint, repository, payload, cwd, mode, entry, signal) };
|
|
409
|
+
} catch (error) {
|
|
410
|
+
if (signal?.aborted || error?.name === 'AbortError') return failure('vcs/cancelled', 'The request was cancelled.');
|
|
411
|
+
const code = error?.code === 'TIMEOUT' ? 'vcs/timeout' : typeof error?.code === 'string' && error.code.startsWith('vcs/') ? error.code : 'vcs/operation-failed';
|
|
412
|
+
return failure(code, error instanceof Error ? error.message : String(error));
|
|
413
|
+
} finally {
|
|
414
|
+
active--;
|
|
415
|
+
if (imageSlot) activeImages--;
|
|
416
|
+
if (segmentSlot) activeSegments--;
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
return async (endpoint, payload, signal) => {
|
|
420
|
+
try {
|
|
421
|
+
return await withOperationBudget({signal, timeoutMs: operationTimeoutMs}, () => execute(endpoint, payload, signal));
|
|
234
422
|
} catch (error) {
|
|
235
423
|
if (signal?.aborted || error?.name === 'AbortError') return failure('vcs/cancelled', 'The request was cancelled.');
|
|
236
|
-
return failure(
|
|
237
|
-
}
|
|
424
|
+
return failure(error?.code === 'TIMEOUT' ? 'vcs/timeout' : 'vcs/operation-failed', error.message);
|
|
425
|
+
}
|
|
238
426
|
};
|
|
239
427
|
}
|
|
240
428
|
|
|
429
|
+
/** Content type is fixed per build artifact; URL input never chooses it. */
|
|
430
|
+
const assetContentType = filename => filename.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8';
|
|
431
|
+
/** Shared bounded-response headers for both asset transports. */
|
|
432
|
+
const assetHeaders = (filename, bytes) => ({
|
|
433
|
+
'Content-Type': assetContentType(filename),
|
|
434
|
+
'Content-Length': String(bytes.byteLength),
|
|
435
|
+
'Cache-Control': 'private, no-cache',
|
|
436
|
+
'X-Content-Type-Options': 'nosniff',
|
|
437
|
+
});
|
|
438
|
+
|
|
241
439
|
/** Three fixed authenticated assets; URL input never enters a filesystem path. */
|
|
242
440
|
export function registerAssets(ctx, load = filename => readFile(new URL('./dist/' + filename, import.meta.url))) {
|
|
243
441
|
for (const filename of ASSETS) {
|
|
@@ -248,12 +446,7 @@ export function registerAssets(ctx, load = filename => readFile(new URL('./dist/
|
|
|
248
446
|
async fetch(request) {
|
|
249
447
|
try {
|
|
250
448
|
const bytes = await load(filename);
|
|
251
|
-
return new Response(request.method === 'HEAD' ? null : bytes, { headers:
|
|
252
|
-
'Content-Type': filename.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8',
|
|
253
|
-
'Content-Length': String(bytes.byteLength),
|
|
254
|
-
'Cache-Control': 'private, no-cache',
|
|
255
|
-
'X-Content-Type-Options': 'nosniff',
|
|
256
|
-
} });
|
|
449
|
+
return new Response(request.method === 'HEAD' ? null : bytes, { headers: assetHeaders(filename, bytes) });
|
|
257
450
|
} catch { return new Response('VCS editor assets unavailable. Build the plugin first.', { status: 503 }); }
|
|
258
451
|
},
|
|
259
452
|
});
|
|
@@ -270,7 +463,7 @@ export function registerDirectAssets(ctx, load = filename => readFile(new URL('.
|
|
|
270
463
|
if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405, {Allow:'GET, HEAD'}); res.end(); return; }
|
|
271
464
|
try {
|
|
272
465
|
const bytes = await load(filename);
|
|
273
|
-
res.writeHead(200,
|
|
466
|
+
res.writeHead(200, assetHeaders(filename, bytes));
|
|
274
467
|
res.end(req.method === 'HEAD' ? undefined : bytes);
|
|
275
468
|
} catch { res.writeHead(503, {'Content-Type':'text/plain; charset=utf-8'}); res.end('VCS editor assets unavailable.'); }
|
|
276
469
|
}
|
|
@@ -281,7 +474,7 @@ export function apply(ctx) {
|
|
|
281
474
|
// Pass the plugin owner explicitly: the nested rpc getter can retain the
|
|
282
475
|
// connection service context, which does not inject webServer.
|
|
283
476
|
const host=createSvnHttpsHost(ctx,adapter);
|
|
284
|
-
ctx.effect(()=>()=>host.dispose());
|
|
477
|
+
ctx.effect(()=>()=>{ host.dispose(); adapter.clearHistoricalCache?.(); });
|
|
285
478
|
ctx.connection.register(ctx, RPC_CHANNEL, host.handle);
|
|
286
479
|
registerAssets(ctx);
|
|
287
480
|
registerDirectAssets(ctx);
|