@git-stunts/git-warp 10.1.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/LICENSE +201 -0
- package/NOTICE +16 -0
- package/README.md +480 -0
- package/SECURITY.md +30 -0
- package/bin/git-warp +24 -0
- package/bin/warp-graph.js +1574 -0
- package/index.d.ts +2366 -0
- package/index.js +180 -0
- package/package.json +129 -0
- package/scripts/install-git-warp.sh +258 -0
- package/scripts/uninstall-git-warp.sh +139 -0
- package/src/domain/WarpGraph.js +3157 -0
- package/src/domain/crdt/Dot.js +160 -0
- package/src/domain/crdt/LWW.js +154 -0
- package/src/domain/crdt/ORSet.js +371 -0
- package/src/domain/crdt/VersionVector.js +222 -0
- package/src/domain/entities/GraphNode.js +60 -0
- package/src/domain/errors/EmptyMessageError.js +47 -0
- package/src/domain/errors/ForkError.js +30 -0
- package/src/domain/errors/IndexError.js +23 -0
- package/src/domain/errors/OperationAbortedError.js +22 -0
- package/src/domain/errors/QueryError.js +39 -0
- package/src/domain/errors/SchemaUnsupportedError.js +17 -0
- package/src/domain/errors/ShardCorruptionError.js +56 -0
- package/src/domain/errors/ShardLoadError.js +57 -0
- package/src/domain/errors/ShardValidationError.js +61 -0
- package/src/domain/errors/StorageError.js +57 -0
- package/src/domain/errors/SyncError.js +30 -0
- package/src/domain/errors/TraversalError.js +23 -0
- package/src/domain/errors/WarpError.js +31 -0
- package/src/domain/errors/WormholeError.js +28 -0
- package/src/domain/errors/WriterError.js +39 -0
- package/src/domain/errors/index.js +21 -0
- package/src/domain/services/AnchorMessageCodec.js +99 -0
- package/src/domain/services/BitmapIndexBuilder.js +225 -0
- package/src/domain/services/BitmapIndexReader.js +435 -0
- package/src/domain/services/BoundaryTransitionRecord.js +463 -0
- package/src/domain/services/CheckpointMessageCodec.js +147 -0
- package/src/domain/services/CheckpointSerializerV5.js +281 -0
- package/src/domain/services/CheckpointService.js +384 -0
- package/src/domain/services/CommitDagTraversalService.js +156 -0
- package/src/domain/services/DagPathFinding.js +712 -0
- package/src/domain/services/DagTopology.js +239 -0
- package/src/domain/services/DagTraversal.js +245 -0
- package/src/domain/services/Frontier.js +108 -0
- package/src/domain/services/GCMetrics.js +101 -0
- package/src/domain/services/GCPolicy.js +122 -0
- package/src/domain/services/GitLogParser.js +205 -0
- package/src/domain/services/HealthCheckService.js +246 -0
- package/src/domain/services/HookInstaller.js +326 -0
- package/src/domain/services/HttpSyncServer.js +262 -0
- package/src/domain/services/IndexRebuildService.js +426 -0
- package/src/domain/services/IndexStalenessChecker.js +103 -0
- package/src/domain/services/JoinReducer.js +582 -0
- package/src/domain/services/KeyCodec.js +113 -0
- package/src/domain/services/LegacyAnchorDetector.js +67 -0
- package/src/domain/services/LogicalTraversal.js +351 -0
- package/src/domain/services/MessageCodecInternal.js +132 -0
- package/src/domain/services/MessageSchemaDetector.js +145 -0
- package/src/domain/services/MigrationService.js +55 -0
- package/src/domain/services/ObserverView.js +265 -0
- package/src/domain/services/PatchBuilderV2.js +669 -0
- package/src/domain/services/PatchMessageCodec.js +140 -0
- package/src/domain/services/ProvenanceIndex.js +337 -0
- package/src/domain/services/ProvenancePayload.js +242 -0
- package/src/domain/services/QueryBuilder.js +835 -0
- package/src/domain/services/StateDiff.js +300 -0
- package/src/domain/services/StateSerializerV5.js +156 -0
- package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
- package/src/domain/services/SyncProtocol.js +593 -0
- package/src/domain/services/TemporalQuery.js +201 -0
- package/src/domain/services/TranslationCost.js +221 -0
- package/src/domain/services/TraversalService.js +8 -0
- package/src/domain/services/WarpMessageCodec.js +29 -0
- package/src/domain/services/WarpStateIndexBuilder.js +127 -0
- package/src/domain/services/WormholeService.js +353 -0
- package/src/domain/types/TickReceipt.js +285 -0
- package/src/domain/types/WarpTypes.js +209 -0
- package/src/domain/types/WarpTypesV2.js +200 -0
- package/src/domain/utils/CachedValue.js +140 -0
- package/src/domain/utils/EventId.js +89 -0
- package/src/domain/utils/LRUCache.js +112 -0
- package/src/domain/utils/MinHeap.js +114 -0
- package/src/domain/utils/RefLayout.js +280 -0
- package/src/domain/utils/WriterId.js +205 -0
- package/src/domain/utils/cancellation.js +33 -0
- package/src/domain/utils/canonicalStringify.js +42 -0
- package/src/domain/utils/defaultClock.js +20 -0
- package/src/domain/utils/defaultCodec.js +51 -0
- package/src/domain/utils/nullLogger.js +21 -0
- package/src/domain/utils/roaring.js +181 -0
- package/src/domain/utils/shardVersion.js +9 -0
- package/src/domain/warp/PatchSession.js +217 -0
- package/src/domain/warp/Writer.js +181 -0
- package/src/hooks/post-merge.sh +60 -0
- package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
- package/src/infrastructure/adapters/ClockAdapter.js +57 -0
- package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
- package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
- package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
- package/src/infrastructure/adapters/NoOpLogger.js +62 -0
- package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
- package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
- package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
- package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
- package/src/infrastructure/codecs/CborCodec.js +384 -0
- package/src/ports/BlobPort.js +30 -0
- package/src/ports/ClockPort.js +25 -0
- package/src/ports/CodecPort.js +25 -0
- package/src/ports/CommitPort.js +114 -0
- package/src/ports/ConfigPort.js +31 -0
- package/src/ports/CryptoPort.js +38 -0
- package/src/ports/GraphPersistencePort.js +57 -0
- package/src/ports/HttpServerPort.js +25 -0
- package/src/ports/IndexStoragePort.js +39 -0
- package/src/ports/LoggerPort.js +68 -0
- package/src/ports/RefPort.js +51 -0
- package/src/ports/TreePort.js +51 -0
- package/src/visualization/index.js +26 -0
- package/src/visualization/layouts/converters.js +75 -0
- package/src/visualization/layouts/elkAdapter.js +86 -0
- package/src/visualization/layouts/elkLayout.js +95 -0
- package/src/visualization/layouts/index.js +29 -0
- package/src/visualization/renderers/ascii/box.js +16 -0
- package/src/visualization/renderers/ascii/check.js +271 -0
- package/src/visualization/renderers/ascii/colors.js +13 -0
- package/src/visualization/renderers/ascii/formatters.js +73 -0
- package/src/visualization/renderers/ascii/graph.js +344 -0
- package/src/visualization/renderers/ascii/history.js +335 -0
- package/src/visualization/renderers/ascii/index.js +14 -0
- package/src/visualization/renderers/ascii/info.js +245 -0
- package/src/visualization/renderers/ascii/materialize.js +255 -0
- package/src/visualization/renderers/ascii/path.js +240 -0
- package/src/visualization/renderers/ascii/progress.js +32 -0
- package/src/visualization/renderers/ascii/symbols.js +33 -0
- package/src/visualization/renderers/ascii/table.js +19 -0
- package/src/visualization/renderers/browser/index.js +1 -0
- package/src/visualization/renderers/svg/index.js +159 -0
- package/src/visualization/utils/ansi.js +14 -0
- package/src/visualization/utils/time.js +40 -0
- package/src/visualization/utils/truncate.js +40 -0
- package/src/visualization/utils/unicode.js +52 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HookInstaller — Installs and manages the post-merge Git hook.
|
|
3
|
+
*
|
|
4
|
+
* Follows hexagonal architecture: all I/O is injected via constructor.
|
|
5
|
+
* The service executes a strategy decided by the caller (CLI handler).
|
|
6
|
+
*
|
|
7
|
+
* @module domain/services/HookInstaller
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DELIMITER_START_PREFIX = '# --- @git-stunts/git-warp post-merge hook';
|
|
11
|
+
const DELIMITER_END = '# --- end @git-stunts/git-warp ---';
|
|
12
|
+
const VERSION_MARKER_PREFIX = '# warp-hook-version:';
|
|
13
|
+
const VERSION_PLACEHOLDER = '__WARP_HOOK_VERSION__';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Classifies an existing hook file's content.
|
|
17
|
+
*
|
|
18
|
+
* Determines whether the hook is absent, ours (with version), or foreign (third-party).
|
|
19
|
+
*
|
|
20
|
+
* @param {string|null} content - File content or null if missing
|
|
21
|
+
* @returns {{ kind: 'none'|'ours'|'foreign', version?: string, appended?: boolean }}
|
|
22
|
+
*/
|
|
23
|
+
export function classifyExistingHook(content) {
|
|
24
|
+
if (!content || content.trim() === '') {
|
|
25
|
+
return { kind: 'none' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const versionMatch = extractVersion(content);
|
|
29
|
+
if (versionMatch) {
|
|
30
|
+
const appended = content.includes(DELIMITER_START_PREFIX);
|
|
31
|
+
return { kind: 'ours', version: versionMatch, appended };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { kind: 'foreign' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Extracts the warp hook version from a hook file's content.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} content - File content to search
|
|
41
|
+
* @returns {string|null} The version string, or null if not found
|
|
42
|
+
* @private
|
|
43
|
+
*/
|
|
44
|
+
function extractVersion(content) {
|
|
45
|
+
for (const line of content.split('\n')) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (trimmed.startsWith(VERSION_MARKER_PREFIX)) {
|
|
48
|
+
const version = trimmed.slice(VERSION_MARKER_PREFIX.length).trim();
|
|
49
|
+
if (version && version !== VERSION_PLACEHOLDER) {
|
|
50
|
+
return version;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class HookInstaller {
|
|
58
|
+
/**
|
|
59
|
+
* Creates a new HookInstaller.
|
|
60
|
+
*
|
|
61
|
+
* @param {Object} deps - Injected dependencies
|
|
62
|
+
* @param {Object} deps.fs - Filesystem adapter with methods: readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, copyFileSync
|
|
63
|
+
* @param {(repoPath: string, key: string) => string|null} deps.execGitConfig - Function to read git config values
|
|
64
|
+
* @param {string} deps.version - Package version
|
|
65
|
+
* @param {string} deps.templateDir - Directory containing hook templates
|
|
66
|
+
* @param {{ join: (...segments: string[]) => string, resolve: (...segments: string[]) => string }} deps.path - Path utilities (join and resolve)
|
|
67
|
+
*/
|
|
68
|
+
constructor({ fs, execGitConfig, version, templateDir, path } = {}) {
|
|
69
|
+
this._fs = fs;
|
|
70
|
+
this._execGitConfig = execGitConfig;
|
|
71
|
+
this._templateDir = templateDir;
|
|
72
|
+
this._version = version;
|
|
73
|
+
this._path = path;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get the current hook status for a repo.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} repoPath - Path to git repo
|
|
80
|
+
* @returns {{ installed: boolean, version?: string, current?: boolean, foreign?: boolean, hookPath: string }}
|
|
81
|
+
*/
|
|
82
|
+
getHookStatus(repoPath) {
|
|
83
|
+
const hookPath = this._resolveHookPath(repoPath);
|
|
84
|
+
const content = this._readFile(hookPath);
|
|
85
|
+
const classification = classifyExistingHook(content);
|
|
86
|
+
|
|
87
|
+
if (classification.kind === 'none') {
|
|
88
|
+
return { installed: false, hookPath };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (classification.kind === 'foreign') {
|
|
92
|
+
return { installed: false, foreign: true, hookPath };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const current = classification.version === this._version;
|
|
96
|
+
return {
|
|
97
|
+
installed: true,
|
|
98
|
+
version: classification.version,
|
|
99
|
+
current,
|
|
100
|
+
hookPath,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Installs the post-merge hook.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} repoPath - Path to git repo
|
|
108
|
+
* @param {Object} opts - Install options
|
|
109
|
+
* @param {'install'|'upgrade'|'append'|'replace'} opts.strategy - Installation strategy
|
|
110
|
+
* @returns {{ action: string, hookPath: string, version: string, backupPath?: string }}
|
|
111
|
+
* @throws {Error} If the strategy is unknown
|
|
112
|
+
*/
|
|
113
|
+
install(repoPath, { strategy }) {
|
|
114
|
+
const hooksDir = this._resolveHooksDir(repoPath);
|
|
115
|
+
const hookPath = this._path.join(hooksDir, 'post-merge');
|
|
116
|
+
const template = this._loadTemplate();
|
|
117
|
+
const stamped = this._stampVersion(template);
|
|
118
|
+
|
|
119
|
+
this._ensureDir(hooksDir);
|
|
120
|
+
|
|
121
|
+
if (strategy === 'install') {
|
|
122
|
+
return this._freshInstall(hookPath, stamped);
|
|
123
|
+
}
|
|
124
|
+
if (strategy === 'upgrade') {
|
|
125
|
+
return this._upgradeInstall(hookPath, stamped);
|
|
126
|
+
}
|
|
127
|
+
if (strategy === 'append') {
|
|
128
|
+
return this._appendInstall(hookPath, stamped);
|
|
129
|
+
}
|
|
130
|
+
if (strategy === 'replace') {
|
|
131
|
+
return this._replaceInstall(hookPath, stamped);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
throw new Error(`Unknown install strategy: ${strategy}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @private */
|
|
138
|
+
_freshInstall(hookPath, content) {
|
|
139
|
+
this._fs.writeFileSync(hookPath, content, { mode: 0o755 });
|
|
140
|
+
this._fs.chmodSync(hookPath, 0o755);
|
|
141
|
+
return {
|
|
142
|
+
action: 'installed',
|
|
143
|
+
hookPath,
|
|
144
|
+
version: this._version,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @private */
|
|
149
|
+
_upgradeInstall(hookPath, stamped) {
|
|
150
|
+
const existing = this._readFile(hookPath);
|
|
151
|
+
const classification = classifyExistingHook(existing);
|
|
152
|
+
|
|
153
|
+
if (classification.appended) {
|
|
154
|
+
const updated = replaceDelimitedSection(existing, stamped);
|
|
155
|
+
// If delimiters were corrupted, replaceDelimitedSection returns unchanged content — fall back to overwrite
|
|
156
|
+
if (updated === existing) {
|
|
157
|
+
this._fs.writeFileSync(hookPath, stamped, { mode: 0o755 });
|
|
158
|
+
} else {
|
|
159
|
+
this._fs.writeFileSync(hookPath, updated, { mode: 0o755 });
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
this._fs.writeFileSync(hookPath, stamped, { mode: 0o755 });
|
|
163
|
+
}
|
|
164
|
+
this._fs.chmodSync(hookPath, 0o755);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
action: 'upgraded',
|
|
168
|
+
hookPath,
|
|
169
|
+
version: this._version,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @private */
|
|
174
|
+
_appendInstall(hookPath, stamped) {
|
|
175
|
+
const existing = this._readFile(hookPath) || '';
|
|
176
|
+
const body = stripShebang(stamped);
|
|
177
|
+
const appended = buildAppendedContent(existing, body);
|
|
178
|
+
this._fs.writeFileSync(hookPath, appended, { mode: 0o755 });
|
|
179
|
+
this._fs.chmodSync(hookPath, 0o755);
|
|
180
|
+
return {
|
|
181
|
+
action: 'appended',
|
|
182
|
+
hookPath,
|
|
183
|
+
version: this._version,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** @private */
|
|
188
|
+
_replaceInstall(hookPath, stamped) {
|
|
189
|
+
const existing = this._readFile(hookPath);
|
|
190
|
+
let backupPath;
|
|
191
|
+
if (existing) {
|
|
192
|
+
backupPath = `${hookPath}.backup`;
|
|
193
|
+
this._fs.writeFileSync(backupPath, existing);
|
|
194
|
+
this._fs.chmodSync(backupPath, 0o755);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this._fs.writeFileSync(hookPath, stamped, { mode: 0o755 });
|
|
198
|
+
this._fs.chmodSync(hookPath, 0o755);
|
|
199
|
+
return {
|
|
200
|
+
action: 'replaced',
|
|
201
|
+
hookPath,
|
|
202
|
+
version: this._version,
|
|
203
|
+
backupPath,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** @private */
|
|
208
|
+
_loadTemplate() {
|
|
209
|
+
const templatePath = this._path.join(this._templateDir, 'post-merge.sh');
|
|
210
|
+
return this._fs.readFileSync(templatePath, 'utf8');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** @private */
|
|
214
|
+
_stampVersion(template) {
|
|
215
|
+
return template.replaceAll(VERSION_PLACEHOLDER, this._version);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** @private */
|
|
219
|
+
_resolveHooksDir(repoPath) {
|
|
220
|
+
const customPath = this._execGitConfig(repoPath, 'core.hooksPath');
|
|
221
|
+
if (customPath) {
|
|
222
|
+
return resolveHooksPath(customPath, repoPath, this._path);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const gitDir = this._execGitConfig(repoPath, '--git-dir');
|
|
226
|
+
if (gitDir) {
|
|
227
|
+
return this._path.join(this._path.resolve(repoPath, gitDir), 'hooks');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return this._path.join(repoPath, '.git', 'hooks');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** @private */
|
|
234
|
+
_resolveHookPath(repoPath) {
|
|
235
|
+
return this._path.join(this._resolveHooksDir(repoPath), 'post-merge');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** @private */
|
|
239
|
+
_readFile(filePath) {
|
|
240
|
+
try {
|
|
241
|
+
return this._fs.readFileSync(filePath, 'utf8');
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** @private */
|
|
248
|
+
_ensureDir(dirPath) {
|
|
249
|
+
if (!this._fs.existsSync(dirPath)) {
|
|
250
|
+
this._fs.mkdirSync(dirPath, { recursive: true });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Resolves a hooks path, handling both absolute and relative paths.
|
|
257
|
+
*
|
|
258
|
+
* @param {string} customPath - The custom hooks path from git config
|
|
259
|
+
* @param {string} repoPath - The repo root path for resolving relative paths
|
|
260
|
+
* @param {{ resolve: (...segments: string[]) => string }} pathUtils - Path utilities
|
|
261
|
+
* @returns {string} Resolved absolute hooks directory path
|
|
262
|
+
* @private
|
|
263
|
+
*/
|
|
264
|
+
function resolveHooksPath(customPath, repoPath, pathUtils) {
|
|
265
|
+
if (customPath.startsWith('/')) {
|
|
266
|
+
return customPath;
|
|
267
|
+
}
|
|
268
|
+
return pathUtils.resolve(repoPath, customPath);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Strips the shebang line from hook content.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} content - Hook file content
|
|
275
|
+
* @returns {string} Content without the shebang line
|
|
276
|
+
* @private
|
|
277
|
+
*/
|
|
278
|
+
function stripShebang(content) {
|
|
279
|
+
const lines = content.split('\n');
|
|
280
|
+
if (lines[0] && lines[0].startsWith('#!')) {
|
|
281
|
+
return lines.slice(1).join('\n');
|
|
282
|
+
}
|
|
283
|
+
return content;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Builds content by appending the warp hook body to existing hook content.
|
|
288
|
+
*
|
|
289
|
+
* @param {string} existing - Existing hook file content
|
|
290
|
+
* @param {string} body - Warp hook body to append (without shebang)
|
|
291
|
+
* @returns {string} Combined hook content
|
|
292
|
+
* @private
|
|
293
|
+
*/
|
|
294
|
+
function buildAppendedContent(existing, body) {
|
|
295
|
+
const trimmed = existing.trimEnd();
|
|
296
|
+
return `${trimmed}\n\n${body}`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Replaces the delimited warp section in an existing hook file.
|
|
301
|
+
*
|
|
302
|
+
* Returns the original content unchanged if delimiters are not found.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} existing - Existing hook file content
|
|
305
|
+
* @param {string} stamped - New version-stamped hook content
|
|
306
|
+
* @returns {string} Updated content with replaced section, or original if delimiters missing
|
|
307
|
+
* @private
|
|
308
|
+
*/
|
|
309
|
+
function replaceDelimitedSection(existing, stamped) {
|
|
310
|
+
const body = stripShebang(stamped);
|
|
311
|
+
const startIdx = existing.indexOf(DELIMITER_START_PREFIX);
|
|
312
|
+
const endIdx = existing.indexOf(DELIMITER_END);
|
|
313
|
+
|
|
314
|
+
if (startIdx === -1 || endIdx === -1) {
|
|
315
|
+
return existing;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const endOfEnd = endIdx + DELIMITER_END.length;
|
|
319
|
+
const before = existing.slice(0, startIdx).trimEnd();
|
|
320
|
+
const after = existing.slice(endOfEnd).trimStart();
|
|
321
|
+
const parts = [before, '', body];
|
|
322
|
+
if (after) {
|
|
323
|
+
parts.push(after);
|
|
324
|
+
}
|
|
325
|
+
return parts.join('\n');
|
|
326
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP sync server extracted from WarpGraph.serve().
|
|
3
|
+
*
|
|
4
|
+
* Handles request routing, JSON parsing, validation, and error responses
|
|
5
|
+
* for the sync protocol. All HTTP I/O flows through an HttpServerPort
|
|
6
|
+
* so the domain never touches node:http directly.
|
|
7
|
+
*
|
|
8
|
+
* @module domain/services/HttpSyncServer
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Recursively sorts object keys for deterministic JSON output.
|
|
15
|
+
*
|
|
16
|
+
* @param {*} value - Any JSON-serializable value
|
|
17
|
+
* @returns {*} The canonicalized value with sorted object keys
|
|
18
|
+
* @private
|
|
19
|
+
*/
|
|
20
|
+
function canonicalizeJson(value) {
|
|
21
|
+
if (Array.isArray(value)) {
|
|
22
|
+
return value.map(canonicalizeJson);
|
|
23
|
+
}
|
|
24
|
+
if (value && typeof value === 'object') {
|
|
25
|
+
const sorted = {};
|
|
26
|
+
for (const key of Object.keys(value).sort()) {
|
|
27
|
+
sorted[key] = canonicalizeJson(value[key]);
|
|
28
|
+
}
|
|
29
|
+
return sorted;
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Produces a canonical JSON string with sorted keys.
|
|
36
|
+
*
|
|
37
|
+
* @param {*} value - Any JSON-serializable value
|
|
38
|
+
* @returns {string} Canonical JSON string
|
|
39
|
+
* @private
|
|
40
|
+
*/
|
|
41
|
+
function canonicalStringify(value) {
|
|
42
|
+
return JSON.stringify(canonicalizeJson(value));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Builds a JSON error response.
|
|
47
|
+
*
|
|
48
|
+
* @param {number} status - HTTP status code
|
|
49
|
+
* @param {string} message - Error message
|
|
50
|
+
* @returns {{ status: number, headers: Object, body: string }}
|
|
51
|
+
* @private
|
|
52
|
+
*/
|
|
53
|
+
function errorResponse(status, message) {
|
|
54
|
+
return {
|
|
55
|
+
status,
|
|
56
|
+
headers: { 'content-type': 'application/json' },
|
|
57
|
+
body: canonicalStringify({ error: message }),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Builds a JSON success response with canonical key ordering.
|
|
63
|
+
*
|
|
64
|
+
* @param {*} data - Response payload
|
|
65
|
+
* @returns {{ status: number, headers: Object, body: string }}
|
|
66
|
+
* @private
|
|
67
|
+
*/
|
|
68
|
+
function jsonResponse(data) {
|
|
69
|
+
return {
|
|
70
|
+
status: 200,
|
|
71
|
+
headers: { 'content-type': 'application/json' },
|
|
72
|
+
body: canonicalStringify(data),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Validates that a sync request object has the expected shape.
|
|
78
|
+
*
|
|
79
|
+
* @param {*} parsed - Parsed JSON body
|
|
80
|
+
* @returns {boolean} True if valid
|
|
81
|
+
* @private
|
|
82
|
+
*/
|
|
83
|
+
function isValidSyncRequest(parsed) {
|
|
84
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (parsed.type !== 'sync-request') {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (!parsed.frontier || typeof parsed.frontier !== 'object' || Array.isArray(parsed.frontier)) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Checks the content-type header. Returns an error response if the
|
|
98
|
+
* content type is present but not application/json, otherwise null.
|
|
99
|
+
*
|
|
100
|
+
* @param {Object} headers - Request headers
|
|
101
|
+
* @returns {{ status: number, headers: Object, body: string }|null}
|
|
102
|
+
* @private
|
|
103
|
+
*/
|
|
104
|
+
function checkContentType(headers) {
|
|
105
|
+
const contentType = ((headers && headers['content-type']) || '').toLowerCase();
|
|
106
|
+
if (contentType && !contentType.startsWith('application/json')) {
|
|
107
|
+
return errorResponse(400, 'Expected application/json');
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parses the request URL and validates the path and method.
|
|
114
|
+
* Returns an error response on failure, or null if valid.
|
|
115
|
+
*
|
|
116
|
+
* @param {{ method: string, url: string, headers: Object }} request
|
|
117
|
+
* @param {string} expectedPath
|
|
118
|
+
* @param {string} defaultHost
|
|
119
|
+
* @returns {{ status: number, headers: Object, body: string }|null}
|
|
120
|
+
* @private
|
|
121
|
+
*/
|
|
122
|
+
function validateRoute(request, expectedPath, defaultHost) {
|
|
123
|
+
let requestUrl;
|
|
124
|
+
try {
|
|
125
|
+
requestUrl = new URL(request.url || '/', `http://${(request.headers && request.headers.host) || defaultHost}`);
|
|
126
|
+
} catch {
|
|
127
|
+
return errorResponse(400, 'Invalid URL');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (requestUrl.pathname !== expectedPath) {
|
|
131
|
+
return errorResponse(404, 'Not Found');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (request.method !== 'POST') {
|
|
135
|
+
return errorResponse(405, 'Method Not Allowed');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Parses and validates the request body as a sync request.
|
|
143
|
+
*
|
|
144
|
+
* @param {Buffer|undefined} body
|
|
145
|
+
* @param {number} maxBytes
|
|
146
|
+
* @returns {{ error: { status: number, headers: Object, body: string }, parsed: null } | { error: null, parsed: Object }}
|
|
147
|
+
* @private
|
|
148
|
+
*/
|
|
149
|
+
function parseBody(body, maxBytes) {
|
|
150
|
+
if (body && body.length > maxBytes) {
|
|
151
|
+
return { error: errorResponse(413, 'Request too large'), parsed: null };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const bodyStr = body ? body.toString('utf-8') : '';
|
|
155
|
+
|
|
156
|
+
let parsed;
|
|
157
|
+
try {
|
|
158
|
+
parsed = bodyStr ? JSON.parse(bodyStr) : null;
|
|
159
|
+
} catch {
|
|
160
|
+
return { error: errorResponse(400, 'Invalid JSON'), parsed: null };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!isValidSyncRequest(parsed)) {
|
|
164
|
+
return { error: errorResponse(400, 'Invalid sync request'), parsed: null };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { error: null, parsed };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export default class HttpSyncServer {
|
|
171
|
+
/**
|
|
172
|
+
* @param {Object} options
|
|
173
|
+
* @param {import('../../ports/HttpServerPort.js').default} options.httpPort - HTTP server port abstraction
|
|
174
|
+
* @param {Object} options.graph - WarpGraph instance (must expose processSyncRequest)
|
|
175
|
+
* @param {string} [options.path='/sync'] - URL path to handle sync requests on
|
|
176
|
+
* @param {string} [options.host='127.0.0.1'] - Host to bind
|
|
177
|
+
* @param {number} [options.maxRequestBytes=4194304] - Maximum request body size in bytes
|
|
178
|
+
*/
|
|
179
|
+
constructor({ httpPort, graph, path = '/sync', host = '127.0.0.1', maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES } = {}) {
|
|
180
|
+
this._httpPort = httpPort;
|
|
181
|
+
this._graph = graph;
|
|
182
|
+
this._path = path && path.startsWith('/') ? path : `/${path || 'sync'}`;
|
|
183
|
+
this._host = host;
|
|
184
|
+
this._maxRequestBytes = maxRequestBytes;
|
|
185
|
+
this._server = null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Handles an incoming HTTP request through the port abstraction.
|
|
190
|
+
*
|
|
191
|
+
* @param {{ method: string, url: string, headers: Object, body: Buffer|undefined }} request
|
|
192
|
+
* @returns {Promise<{ status: number, headers: Object, body: string }>}
|
|
193
|
+
* @private
|
|
194
|
+
*/
|
|
195
|
+
async _handleRequest(request) {
|
|
196
|
+
const contentTypeError = checkContentType(request.headers);
|
|
197
|
+
if (contentTypeError) {
|
|
198
|
+
return contentTypeError;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const routeError = validateRoute(request, this._path, this._host);
|
|
202
|
+
if (routeError) {
|
|
203
|
+
return routeError;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const { error, parsed } = parseBody(request.body, this._maxRequestBytes);
|
|
207
|
+
if (error) {
|
|
208
|
+
return error;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const response = await this._graph.processSyncRequest(parsed);
|
|
213
|
+
return jsonResponse(response);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
return errorResponse(500, err?.message || 'Sync failed');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Starts the HTTP sync server.
|
|
221
|
+
*
|
|
222
|
+
* @param {number} port - Port to listen on (0 for ephemeral)
|
|
223
|
+
* @returns {Promise<{ url: string, close: () => Promise<void> }>}
|
|
224
|
+
* @throws {Error} If port is not a number
|
|
225
|
+
*/
|
|
226
|
+
async listen(port) {
|
|
227
|
+
if (typeof port !== 'number') {
|
|
228
|
+
throw new Error('listen() requires a numeric port');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const server = this._httpPort.createServer((request) => this._handleRequest(request));
|
|
232
|
+
this._server = server;
|
|
233
|
+
|
|
234
|
+
await new Promise((resolve, reject) => {
|
|
235
|
+
server.listen(port, this._host, (err) => {
|
|
236
|
+
if (err) {
|
|
237
|
+
reject(err);
|
|
238
|
+
} else {
|
|
239
|
+
resolve();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const address = server.address();
|
|
245
|
+
const actualPort = typeof address === 'object' && address ? address.port : port;
|
|
246
|
+
const url = `http://${this._host}:${actualPort}${this._path}`;
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
url,
|
|
250
|
+
close: () =>
|
|
251
|
+
new Promise((resolve, reject) => {
|
|
252
|
+
server.close((err) => {
|
|
253
|
+
if (err) {
|
|
254
|
+
reject(err);
|
|
255
|
+
} else {
|
|
256
|
+
resolve();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|