canopycms 0.0.67 → 0.0.68-int.100
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 +21 -14
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +17 -20
- package/dist/api/admin.d.ts +30 -9
- package/dist/api/admin.js +32 -2
- package/dist/api/assets.js +84 -76
- package/dist/api/branch-create-window.d.ts +12 -0
- package/dist/api/branch-create-window.js +13 -0
- package/dist/api/branch-review.d.ts +3 -3
- package/dist/api/branch-review.js +14 -5
- package/dist/api/branch-status.d.ts +5 -5
- package/dist/api/branch-status.js +26 -6
- package/dist/api/branch-withdraw.d.ts +2 -2
- package/dist/api/branch-withdraw.js +7 -2
- package/dist/api/branch.d.ts +15 -2
- package/dist/api/branch.js +111 -92
- package/dist/api/client.d.ts +18 -6
- package/dist/api/client.js +79 -12
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +44 -41
- package/dist/api/entries.js +16 -15
- package/dist/api/github-sync.d.ts +10 -1
- package/dist/api/github-sync.js +20 -10
- package/dist/api/index.d.ts +1 -1
- package/dist/api/reference-options.js +1 -1
- package/dist/api/resolve-references.js +14 -40
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/api/user.d.ts +3 -0
- package/dist/api/user.js +3 -0
- package/dist/assets/asset-url.d.ts +22 -16
- package/dist/assets/asset-url.js +42 -25
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/assets/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +122 -0
- package/dist/assets/materialize.js +305 -0
- package/dist/assets/store-local.d.ts +4 -0
- package/dist/assets/store-local.js +11 -0
- package/dist/assets/store-s3.d.ts +10 -1
- package/dist/assets/store-s3.js +55 -3
- package/dist/assets/transform-directives.d.ts +52 -13
- package/dist/assets/transform-directives.js +87 -27
- package/dist/assets/transform.d.ts +3 -3
- package/dist/assets/transform.js +13 -8
- package/dist/assets/types.d.ts +14 -1
- package/dist/auth/file-based-auth-cache.js +7 -8
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.d.ts +7 -1
- package/dist/branch-health.js +8 -3
- package/dist/branch-metadata.d.ts +5 -5
- package/dist/branch-metadata.js +44 -36
- package/dist/branch-provisioning.d.ts +167 -0
- package/dist/branch-provisioning.js +501 -0
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-sparse.d.ts +23 -0
- package/dist/branch-sparse.js +118 -0
- package/dist/branch-workspace.d.ts +42 -3
- package/dist/branch-workspace.js +240 -94
- package/dist/build/asset-refs.d.ts +93 -0
- package/dist/build/asset-refs.js +251 -0
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/asset-refs.d.ts +30 -0
- package/dist/cli/asset-refs.js +168 -0
- package/dist/cli/cli.d.ts +2 -1
- package/dist/cli/cli.js +4843 -1870
- package/dist/cli/generate-ai-content.js +1584 -444
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +30 -27
- package/dist/cli/migrate.js +12 -8
- package/dist/cli/sync.js +83 -37
- package/dist/cli/template-files/Dockerfile.cms.template +8 -0
- package/dist/cli/template-files/cdk-app.ts.template +4 -0
- package/dist/cli/template-files/cms-stack.ts.template +23 -12
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/config/helpers.js +1 -2
- package/dist/config/schemas/config.d.ts +19 -32
- package/dist/config/schemas/config.js +9 -11
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/media.d.ts +4 -13
- package/dist/config/schemas/media.js +5 -8
- package/dist/config/schemas/url.d.ts +4 -20
- package/dist/config/schemas/url.js +6 -22
- package/dist/config/types.d.ts +30 -20
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +12 -12
- package/dist/content-listing.js +10 -6
- package/dist/content-reader.d.ts +11 -2
- package/dist/content-reader.js +22 -14
- package/dist/content-store.d.ts +37 -9
- package/dist/content-store.js +70 -43
- package/dist/content-tree.d.ts +1 -1
- package/dist/content-tree.js +2 -2
- package/dist/context.d.ts +27 -9
- package/dist/context.js +154 -70
- package/dist/editor/BranchManager.d.ts +7 -1
- package/dist/editor/BranchManager.js +29 -14
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +2 -6
- package/dist/editor/Editor.d.ts +4 -3
- package/dist/editor/Editor.js +24 -20
- package/dist/editor/FormRenderer.js +34 -18
- package/dist/editor/admin/SystemHealthPanel.js +47 -7
- package/dist/editor/components/BranchesDrawer.d.ts +9 -0
- package/dist/editor/components/BranchesDrawer.js +4 -0
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/context/AssetContext.d.ts +18 -35
- package/dist/editor/context/AssetContext.js +20 -29
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/editor-config.d.ts +1 -2
- package/dist/editor/editor-config.js +0 -23
- package/dist/editor/editor-utils.d.ts +24 -21
- package/dist/editor/editor-utils.js +75 -62
- package/dist/editor/fields/BlockField.d.ts +1 -0
- package/dist/editor/fields/BlockField.js +6 -4
- package/dist/editor/fields/CodeField.d.ts +1 -0
- package/dist/editor/fields/CodeField.js +2 -2
- package/dist/editor/fields/DateTimeField.d.ts +1 -0
- package/dist/editor/fields/DateTimeField.js +2 -2
- package/dist/editor/fields/FieldDescription.d.ts +16 -0
- package/dist/editor/fields/FieldDescription.js +11 -0
- package/dist/editor/fields/ImageField.d.ts +1 -0
- package/dist/editor/fields/ImageField.js +3 -2
- package/dist/editor/fields/InlineGroupField.js +4 -1
- package/dist/editor/fields/MarkdownField.d.ts +1 -0
- package/dist/editor/fields/MarkdownField.js +115 -20
- package/dist/editor/fields/NumberField.d.ts +1 -0
- package/dist/editor/fields/NumberField.js +2 -2
- package/dist/editor/fields/NumberListField.d.ts +1 -0
- package/dist/editor/fields/NumberListField.js +2 -2
- package/dist/editor/fields/ObjectField.d.ts +1 -0
- package/dist/editor/fields/ObjectField.js +5 -2
- package/dist/editor/fields/ReferenceField.d.ts +1 -0
- package/dist/editor/fields/ReferenceField.js +5 -4
- package/dist/editor/fields/SelectField.d.ts +1 -0
- package/dist/editor/fields/SelectField.js +2 -2
- package/dist/editor/fields/StringListField.d.ts +1 -0
- package/dist/editor/fields/StringListField.js +2 -2
- package/dist/editor/fields/TextField.d.ts +1 -0
- package/dist/editor/fields/TextField.js +2 -2
- package/dist/editor/fields/ToggleField.d.ts +1 -0
- package/dist/editor/fields/ToggleField.js +7 -4
- package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
- package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
- package/dist/editor/fields/mdx-jsx-support.js +137 -0
- package/dist/editor/hooks/create-branch-request.d.ts +20 -0
- package/dist/editor/hooks/create-branch-request.js +52 -0
- package/dist/editor/hooks/useBranchActions.d.ts +13 -2
- package/dist/editor/hooks/useBranchActions.js +48 -17
- package/dist/editor/hooks/useBranchManager.d.ts +11 -0
- package/dist/editor/hooks/useBranchManager.js +80 -2
- package/dist/editor/hooks/useBranchesData.d.ts +6 -0
- package/dist/editor/hooks/useBranchesData.js +9 -3
- package/dist/editor/hooks/useCommentSystem.js +25 -6
- package/dist/editor/hooks/useDraftManager.d.ts +16 -1
- package/dist/editor/hooks/useDraftManager.js +193 -36
- package/dist/editor/hooks/useEntriesData.js +3 -3
- package/dist/editor/hooks/useEntryManager.d.ts +2 -1
- package/dist/editor/hooks/useEntryManager.js +34 -19
- package/dist/editor/media/AssetCard.d.ts +1 -1
- package/dist/editor/media/crop-math.d.ts +3 -4
- package/dist/editor/media/crop-math.js +11 -22
- package/dist/editor/media/editor-image-src.d.ts +7 -0
- package/dist/editor/media/editor-image-src.js +12 -0
- package/dist/editor/preview-asset-base.d.ts +8 -0
- package/dist/editor/preview-asset-base.js +13 -0
- package/dist/editor/preview-bridge.d.ts +8 -1
- package/dist/editor/preview-bridge.js +15 -2
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/editor/theme.js +2 -2
- package/dist/entry-schema-registry.d.ts +4 -4
- package/dist/entry-schema-registry.js +10 -6
- package/dist/entry-schema.d.ts +27 -4
- package/dist/entry-schema.js +20 -3
- package/dist/git-manager.d.ts +173 -6
- package/dist/git-manager.js +594 -76
- package/dist/github-service.d.ts +8 -0
- package/dist/github-service.js +5 -1
- package/dist/http/handler.js +37 -13
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/types.d.ts +2 -0
- package/dist/http/worker-not-ready.d.ts +10 -0
- package/dist/http/worker-not-ready.js +24 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +1 -4
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -2
- package/dist/paths/branch.js +16 -10
- package/dist/paths/index.d.ts +3 -3
- package/dist/paths/index.js +3 -3
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/paths/validation.js +0 -2
- package/dist/reference-resolver.d.ts +5 -21
- package/dist/reference-resolver.js +9 -44
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/schema/schema-store.d.ts +54 -31
- package/dist/schema/schema-store.js +77 -31
- package/dist/server.d.ts +24 -9
- package/dist/server.js +22 -8
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.d.ts +23 -3
- package/dist/settings-workspace.js +197 -45
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.d.ts +12 -1
- package/dist/sync-core.js +31 -13
- package/dist/task-queue/worker-status.d.ts +8 -0
- package/dist/task-queue/worker-status.js +17 -0
- package/dist/types.d.ts +31 -2
- package/dist/utils/content-write-lock.d.ts +18 -7
- package/dist/utils/content-write-lock.js +18 -9
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +32 -0
- package/dist/utils/git.js +42 -0
- package/dist/utils/occ-json-write.js +10 -2
- package/dist/utils/provision-log.d.ts +25 -0
- package/dist/utils/provision-log.js +49 -0
- package/dist/utils/provisioning-lock.d.ts +35 -5
- package/dist/utils/provisioning-lock.js +73 -12
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/sanitize-href.d.ts +8 -22
- package/dist/utils/sanitize-href.js +11 -28
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/validation/entry-validator.js +9 -3
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/cms-worker.d.ts +8 -3
- package/dist/worker/cms-worker.js +38 -2
- package/dist/worker/git-sync.d.ts +30 -12
- package/dist/worker/git-sync.js +253 -58
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +95 -23
- package/dist/worker/remote-git-maintenance.d.ts +3 -0
- package/dist/worker/remote-git-maintenance.js +11 -0
- package/dist/worker/sparse-cone.d.ts +20 -0
- package/dist/worker/sparse-cone.js +132 -0
- package/dist/worker/task-runner.js +39 -4
- package/dist/worker/worker-context.d.ts +6 -1
- package/package.json +2 -1
package/dist/utils/debug.js
CHANGED
|
@@ -55,6 +55,10 @@ export class DebugLogger {
|
|
|
55
55
|
throw new Error(errorMsg);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Keyed by label alone, so two overlapping timers with one label overwrite each other;
|
|
60
|
+
* anything that can run concurrently uses {@link timed}.
|
|
61
|
+
*/
|
|
58
62
|
time(label) {
|
|
59
63
|
this.timers.set(label, Date.now());
|
|
60
64
|
}
|
|
@@ -69,13 +73,17 @@ export class DebugLogger {
|
|
|
69
73
|
this.debug(category, `${label} completed`, { durationMs: duration });
|
|
70
74
|
return duration;
|
|
71
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Log `<label> completed {durationMs}` once `fn` settles. The start time is local to the
|
|
78
|
+
* call, so concurrent spans with the same label on one logger never overwrite each other.
|
|
79
|
+
*/
|
|
72
80
|
async timed(category, label, fn) {
|
|
73
|
-
|
|
81
|
+
const start = Date.now();
|
|
74
82
|
try {
|
|
75
83
|
return await fn();
|
|
76
84
|
}
|
|
77
85
|
finally {
|
|
78
|
-
this.
|
|
86
|
+
this.debug(category, `${label} completed`, { durationMs: Date.now() - start });
|
|
79
87
|
}
|
|
80
88
|
}
|
|
81
89
|
}
|
package/dist/utils/git.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SimpleGit } from 'simple-git';
|
|
1
2
|
import type { OperatingMode } from '../operating-mode/index.js';
|
|
2
3
|
/**
|
|
3
4
|
* Whether a git remote URL points at a network location rather than a local filesystem path. Only
|
|
@@ -39,6 +40,15 @@ export declare function isNonFastForwardRejection(message: string): boolean;
|
|
|
39
40
|
* surfacing as the permanent, human-actionable state it is. Same locale caveat as above.
|
|
40
41
|
*/
|
|
41
42
|
export declare function isStaleLeaseRejection(message: string): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The workflow file named by GitHub's refusal of a push that would add workflow content the
|
|
45
|
+
* credential lacks the workflows permission for, or null if the message is not that refusal.
|
|
46
|
+
*
|
|
47
|
+
* GitHub refuses only content it does not already hold: carrying a base-branch workflow change by
|
|
48
|
+
* rebase, merge or fast-forward is accepted. Retrying the identical push can never succeed, so the
|
|
49
|
+
* worker fails it fast. The text is GitHub's own, not git's, so no locale pinning is needed.
|
|
50
|
+
*/
|
|
51
|
+
export declare function workflowPushRefusalFile(message: string): string | null;
|
|
42
52
|
/**
|
|
43
53
|
* Whether a `git fetch <remote> <branch>` failure is specifically "that ref doesn't exist on the
|
|
44
54
|
* remote" -- the ONLY benign fetch outcome, meaning the branch has never been pushed.
|
|
@@ -50,6 +60,28 @@ export declare function isStaleLeaseRejection(message: string): boolean;
|
|
|
50
60
|
* caveat as the predicates above.
|
|
51
61
|
*/
|
|
52
62
|
export declare function isMissingRemoteRefFailure(message: string): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* The directory, at every workspace root, holding canopycms's own per-workspace state: branch
|
|
65
|
+
* metadata, comments, generation markers and lock markers. None of it is content, so it never
|
|
66
|
+
* belongs in a commit.
|
|
67
|
+
*/
|
|
68
|
+
export declare const CANOPY_META_DIR = ".canopy-meta";
|
|
69
|
+
/**
|
|
70
|
+
* Whether a repo-relative path from `git status` / `git ls-files` is {@link CANOPY_META_DIR}
|
|
71
|
+
* or lies under it.
|
|
72
|
+
*/
|
|
73
|
+
export declare function isCanopyInternalPath(repoRelativePath: string): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Stage every working-tree change (additions, edits, deletions) except anything under
|
|
76
|
+
* {@link CANOPY_META_DIR}, including files an adopter committed there by mistake. Stage-all then
|
|
77
|
+
* unstage, because git rejects `:(exclude)` pathspecs (six spellings tried) with "paths are ignored" when
|
|
78
|
+
* the excluded directory is itself ignored, which `.git/info/exclude` makes it in every clone.
|
|
79
|
+
*
|
|
80
|
+
* `--sparse`: in a sparse clone a plain `add -A` fails on any file present outside the cone.
|
|
81
|
+
* With it, such a file is staged like any other, and a tracked file absent only because it is
|
|
82
|
+
* outside the cone is still not staged as a deletion. A no-op in a full clone.
|
|
83
|
+
*/
|
|
84
|
+
export declare function stageAllExceptCanopyState(git: SimpleGit): Promise<void>;
|
|
53
85
|
/**
|
|
54
86
|
* Whether a repository has an INTERRUPTED rebase on disk — the `rebase-merge` (interactive/merge
|
|
55
87
|
* backend) or `rebase-apply` (am backend) state directory git leaves when a rebase stops for
|
package/dist/utils/git.js
CHANGED
|
@@ -88,6 +88,21 @@ const STALE_LEASE_REASON = 'stale info';
|
|
|
88
88
|
export function isStaleLeaseRejection(message) {
|
|
89
89
|
return message.includes(REJECTED_MARKER) && message.includes(STALE_LEASE_REASON);
|
|
90
90
|
}
|
|
91
|
+
// GitHub's reason text when a push would introduce workflow content the credential may not write.
|
|
92
|
+
// It names the credential kind ("a GitHub App", "an OAuth App", ...) and then the file.
|
|
93
|
+
// Both classes stop at a newline, so the match stays on the one status line that carries it.
|
|
94
|
+
const WORKFLOW_REFUSAL_PATTERN = /refusing to allow an? [^`\n]+? to create or update workflow `([^`\n]+)`/;
|
|
95
|
+
/**
|
|
96
|
+
* The workflow file named by GitHub's refusal of a push that would add workflow content the
|
|
97
|
+
* credential lacks the workflows permission for, or null if the message is not that refusal.
|
|
98
|
+
*
|
|
99
|
+
* GitHub refuses only content it does not already hold: carrying a base-branch workflow change by
|
|
100
|
+
* rebase, merge or fast-forward is accepted. Retrying the identical push can never succeed, so the
|
|
101
|
+
* worker fails it fast. The text is GitHub's own, not git's, so no locale pinning is needed.
|
|
102
|
+
*/
|
|
103
|
+
export function workflowPushRefusalFile(message) {
|
|
104
|
+
return WORKFLOW_REFUSAL_PATTERN.exec(message)?.[1] ?? null;
|
|
105
|
+
}
|
|
91
106
|
// git's message when `git fetch <remote> <branch>` names a ref the remote does not have. Both
|
|
92
107
|
// spellings occur: modern git prints the lowercase form, older versions and some transports
|
|
93
108
|
// capitalize it.
|
|
@@ -135,6 +150,33 @@ async function resolveGitDir(repoPath) {
|
|
|
135
150
|
return null;
|
|
136
151
|
}
|
|
137
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* The directory, at every workspace root, holding canopycms's own per-workspace state: branch
|
|
155
|
+
* metadata, comments, generation markers and lock markers. None of it is content, so it never
|
|
156
|
+
* belongs in a commit.
|
|
157
|
+
*/
|
|
158
|
+
export const CANOPY_META_DIR = '.canopy-meta';
|
|
159
|
+
/**
|
|
160
|
+
* Whether a repo-relative path from `git status` / `git ls-files` is {@link CANOPY_META_DIR}
|
|
161
|
+
* or lies under it.
|
|
162
|
+
*/
|
|
163
|
+
export function isCanopyInternalPath(repoRelativePath) {
|
|
164
|
+
return repoRelativePath === CANOPY_META_DIR || repoRelativePath.startsWith(`${CANOPY_META_DIR}/`);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Stage every working-tree change (additions, edits, deletions) except anything under
|
|
168
|
+
* {@link CANOPY_META_DIR}, including files an adopter committed there by mistake. Stage-all then
|
|
169
|
+
* unstage, because git rejects `:(exclude)` pathspecs (six spellings tried) with "paths are ignored" when
|
|
170
|
+
* the excluded directory is itself ignored, which `.git/info/exclude` makes it in every clone.
|
|
171
|
+
*
|
|
172
|
+
* `--sparse`: in a sparse clone a plain `add -A` fails on any file present outside the cone.
|
|
173
|
+
* With it, such a file is staged like any other, and a tracked file absent only because it is
|
|
174
|
+
* outside the cone is still not staged as a deletion. A no-op in a full clone.
|
|
175
|
+
*/
|
|
176
|
+
export async function stageAllExceptCanopyState(git) {
|
|
177
|
+
await git.raw(['add', '-A', '--sparse']);
|
|
178
|
+
await git.raw(['reset', '-q', '--', CANOPY_META_DIR]);
|
|
179
|
+
}
|
|
138
180
|
/**
|
|
139
181
|
* Whether a repository has an INTERRUPTED rebase on disk — the `rebase-merge` (interactive/merge
|
|
140
182
|
* backend) or `rebase-apply` (am backend) state directory git leaves when a rebase stops for
|
|
@@ -158,8 +158,16 @@ export async function withOccRetry(operation, options) {
|
|
|
158
158
|
* legitimately take many seconds.
|
|
159
159
|
*/
|
|
160
160
|
export async function withOccFileLock(filePath, fn) {
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
// The lock's own directory only, never its ancestors: a waiter must not recreate a branch root
|
|
162
|
+
// that a delete removed under it (the same rule as the ENOENT case in the loop below).
|
|
163
|
+
try {
|
|
164
|
+
await fs.mkdir(path.dirname(filePath));
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
if (!isNodeError(err) || err.code !== 'EEXIST') {
|
|
168
|
+
throw new OccWriteConflictError(`Could not acquire file lock: ${getErrorMessage(err)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
163
171
|
// Acquisition retries are OUR loop, not proper-lockfile's built-in
|
|
164
172
|
// `retries`: the built-in loop retries blindly on ANY error, so a waiter
|
|
165
173
|
// whose target directory was deleted mid-poll (deleteBranch's rm removing
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replace where step lines go; no argument restores `canopyLog`. The test
|
|
3
|
+
* setup silences them, since nearly every suite provisions a workspace.
|
|
4
|
+
* @internal Exported for tests.
|
|
5
|
+
*/
|
|
6
|
+
export declare function setProvisionLogSink(next?: (line: string) => void): void;
|
|
7
|
+
/**
|
|
8
|
+
* Per-step timing for one workspace provisioning, printed unconditionally
|
|
9
|
+
* (never behind `CANOPYCMS_DEBUG`). A request killed mid-step, such as a Lambda
|
|
10
|
+
* at its timeout, never reaches its own error handling, so each step prints a
|
|
11
|
+
* `start` line and a `done` line: the last line in the log names the step that
|
|
12
|
+
* was running.
|
|
13
|
+
*
|
|
14
|
+
* Lines read `[canopy] provision id=<id> dir=<dir> step=<step> start|done ms=<n>|failed ms=<n>`,
|
|
15
|
+
* then one `outcome=<outcome> total=<ms> <step>=<ms> …` summary.
|
|
16
|
+
*/
|
|
17
|
+
export declare class ProvisionLog {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
private readonly startedAt;
|
|
20
|
+
private readonly durations;
|
|
21
|
+
private readonly prefix;
|
|
22
|
+
constructor(dir: string);
|
|
23
|
+
step<T>(name: string, run: () => Promise<T>): Promise<T>;
|
|
24
|
+
finish(outcome: string): void;
|
|
25
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { canopyLog } from './logger.js';
|
|
3
|
+
const defaultSink = (line) => canopyLog(line);
|
|
4
|
+
let sink = defaultSink;
|
|
5
|
+
/**
|
|
6
|
+
* Replace where step lines go; no argument restores `canopyLog`. The test
|
|
7
|
+
* setup silences them, since nearly every suite provisions a workspace.
|
|
8
|
+
* @internal Exported for tests.
|
|
9
|
+
*/
|
|
10
|
+
export function setProvisionLogSink(next) {
|
|
11
|
+
sink = next ?? defaultSink;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Per-step timing for one workspace provisioning, printed unconditionally
|
|
15
|
+
* (never behind `CANOPYCMS_DEBUG`). A request killed mid-step, such as a Lambda
|
|
16
|
+
* at its timeout, never reaches its own error handling, so each step prints a
|
|
17
|
+
* `start` line and a `done` line: the last line in the log names the step that
|
|
18
|
+
* was running.
|
|
19
|
+
*
|
|
20
|
+
* Lines read `[canopy] provision id=<id> dir=<dir> step=<step> start|done ms=<n>|failed ms=<n>`,
|
|
21
|
+
* then one `outcome=<outcome> total=<ms> <step>=<ms> …` summary.
|
|
22
|
+
*/
|
|
23
|
+
export class ProvisionLog {
|
|
24
|
+
constructor(dir) {
|
|
25
|
+
this.id = randomBytes(3).toString('hex');
|
|
26
|
+
this.startedAt = Date.now();
|
|
27
|
+
this.durations = [];
|
|
28
|
+
this.prefix = `[canopy] provision id=${this.id} dir=${dir}`;
|
|
29
|
+
}
|
|
30
|
+
async step(name, run) {
|
|
31
|
+
sink(`${this.prefix} step=${name} start`);
|
|
32
|
+
const stepStartedAt = Date.now();
|
|
33
|
+
let state = 'failed';
|
|
34
|
+
try {
|
|
35
|
+
const result = await run();
|
|
36
|
+
state = 'done';
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
const ms = Date.now() - stepStartedAt;
|
|
41
|
+
this.durations.push(`${name}=${ms}`);
|
|
42
|
+
sink(`${this.prefix} step=${name} ${state} ms=${ms}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
finish(outcome) {
|
|
46
|
+
const total = Date.now() - this.startedAt;
|
|
47
|
+
sink(`${this.prefix} outcome=${outcome} total=${total} ${this.durations.join(' ')}`.trim());
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -9,6 +9,19 @@
|
|
|
9
9
|
* inside the refresh timer, so a throw is an uncaught exception that kills the process.
|
|
10
10
|
*/
|
|
11
11
|
export type OnLockCompromised = (err: Error) => void;
|
|
12
|
+
/**
|
|
13
|
+
* When an acquirer may take a provisioning marker over. Staleness is read from the marker's mtime,
|
|
14
|
+
* which EFS serves from the NFS attribute cache for up to 60s, so a live holder's 15s refreshes
|
|
15
|
+
* can look 60s late; one threshold above that for every acquirer means none reaps a live hold.
|
|
16
|
+
*/
|
|
17
|
+
export declare const PROVISIONING_LOCK_STALE_MS = 90000;
|
|
18
|
+
/**
|
|
19
|
+
* The provisioning lock's name for the branch workspace directory `dirName`, held in that
|
|
20
|
+
* directory's parent. Every party to a branch workspace's provisioning names it through this:
|
|
21
|
+
* the Lambda while it clones, the admin purge, branch-health's freshness rail, and the worker
|
|
22
|
+
* while it refreshes or rebases.
|
|
23
|
+
*/
|
|
24
|
+
export declare function branchProvisioningLockName(dirName: string): string;
|
|
12
25
|
/**
|
|
13
26
|
* Acquire a cross-process filesystem lock for content provisioning. Returns a release function —
|
|
14
27
|
* always call it in a `finally`.
|
|
@@ -27,12 +40,29 @@ export declare function acquireProvisioningLock(lockTargetDir: string, lockName:
|
|
|
27
40
|
* request/response cycle (a Lambda-backed API handler). `acquireProvisioningLock`'s ~600-retry
|
|
28
41
|
* budget waits minutes for a live provisioner; an admin request must fail fast instead.
|
|
29
42
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
43
|
+
* Staleness recovery is unchanged from the patient variant: a genuinely stale lock is still taken
|
|
44
|
+
* over normally. Only the RETRY loop for live contention is removed, not the staleness recovery a
|
|
45
|
+
* caller depends on -- see branch-health.ts's [H1] freshness rail, which reads this lock's mtime
|
|
46
|
+
* before an admin purge/repair proceeds.
|
|
34
47
|
*
|
|
35
48
|
* Throws with `err.code === 'ELOCKED'` on contention (a live, non-stale holder) -- callers
|
|
36
49
|
* translate that into a 409.
|
|
50
|
+
*
|
|
51
|
+
* @param staleMs how old the marker must look before this caller takes it over; defaults to
|
|
52
|
+
* {@link PROVISIONING_LOCK_STALE_MS}
|
|
53
|
+
* @param createParents whether a missing parent of `lockTargetDir` is created. A lock inside a
|
|
54
|
+
* branch passes false: a waiter must never recreate a branch root a delete removed, so a missing
|
|
55
|
+
* parent fails with ENOENT instead.
|
|
56
|
+
*/
|
|
57
|
+
export declare function tryAcquireProvisioningLock(lockTargetDir: string, lockName: string, onCompromised?: OnLockCompromised, staleMs?: number, createParents?: boolean): Promise<() => Promise<void>>;
|
|
58
|
+
/**
|
|
59
|
+
* {@link tryAcquireProvisioningLock} retried for at most `waitMs`, for a caller whose own hold is
|
|
60
|
+
* milliseconds long (publishing a staged branch workspace). A dead holder's marker stays live for
|
|
61
|
+
* {@link PROVISIONING_LOCK_STALE_MS}, so waiting it out from a request would spend the request's
|
|
62
|
+
* whole timeout; this gives up instead.
|
|
63
|
+
*
|
|
64
|
+
* Retries only on `ELOCKED`, like the content-write lock's bounded wait.
|
|
65
|
+
*
|
|
66
|
+
* @throws the last `ELOCKED` error once the budget is spent
|
|
37
67
|
*/
|
|
38
|
-
export declare function
|
|
68
|
+
export declare function acquireProvisioningLockWithin(lockTargetDir: string, lockName: string, waitMs: number, onCompromised?: OnLockCompromised): Promise<() => Promise<void>>;
|
|
@@ -3,6 +3,12 @@ import path from 'node:path';
|
|
|
3
3
|
import lockfile from 'proper-lockfile';
|
|
4
4
|
import { getErrorMessage, isNodeError } from './error.js';
|
|
5
5
|
import { canopyLogWarn } from './logger.js';
|
|
6
|
+
/**
|
|
7
|
+
* When an acquirer may take a provisioning marker over. Staleness is read from the marker's mtime,
|
|
8
|
+
* which EFS serves from the NFS attribute cache for up to 60s, so a live holder's 15s refreshes
|
|
9
|
+
* can look 60s late; one threshold above that for every acquirer means none reaps a live hold.
|
|
10
|
+
*/
|
|
11
|
+
export const PROVISIONING_LOCK_STALE_MS = 90_000;
|
|
6
12
|
/**
|
|
7
13
|
* Shared option set for both provisioning-lock variants.
|
|
8
14
|
*
|
|
@@ -16,12 +22,14 @@ import { canopyLogWarn } from './logger.js';
|
|
|
16
22
|
* share one. `realpath: false` because that anchor path does not exist before we create it
|
|
17
23
|
* (realpath would ENOENT). See docs/concurrency.md ("Anchor path matters").
|
|
18
24
|
*/
|
|
19
|
-
function provisioningLockOptions(lockPath, retries, onCompromised) {
|
|
25
|
+
function provisioningLockOptions(lockPath, retries, onCompromised, staleMs) {
|
|
20
26
|
return {
|
|
21
27
|
lockfilePath: lockPath,
|
|
22
28
|
realpath: false,
|
|
23
29
|
retries,
|
|
24
|
-
stale:
|
|
30
|
+
stale: staleMs,
|
|
31
|
+
// Fixed, not stale/2: every holder refreshes at 15s whatever threshold it judges others by.
|
|
32
|
+
update: 15_000,
|
|
25
33
|
// proper-lockfile invokes this from inside its refresh timer, so ANY throw escaping here is
|
|
26
34
|
// an uncaught exception that kills the process. Call sites are told not to throw (see
|
|
27
35
|
// OnLockCompromised); this makes it structural rather than a convention, and also covers the
|
|
@@ -76,6 +84,15 @@ function releaseIgnoringAlreadyReleased(release, lockPath) {
|
|
|
76
84
|
}
|
|
77
85
|
};
|
|
78
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* The provisioning lock's name for the branch workspace directory `dirName`, held in that
|
|
89
|
+
* directory's parent. Every party to a branch workspace's provisioning names it through this:
|
|
90
|
+
* the Lambda while it clones, the admin purge, branch-health's freshness rail, and the worker
|
|
91
|
+
* while it refreshes or rebases.
|
|
92
|
+
*/
|
|
93
|
+
export function branchProvisioningLockName(dirName) {
|
|
94
|
+
return `.${dirName}.init.lock`;
|
|
95
|
+
}
|
|
79
96
|
/**
|
|
80
97
|
* Acquire a cross-process filesystem lock for content provisioning. Returns a release function —
|
|
81
98
|
* always call it in a `finally`.
|
|
@@ -94,9 +111,9 @@ export async function acquireProvisioningLock(lockTargetDir, lockName, onComprom
|
|
|
94
111
|
// Generous, jittered retries: several processes may contend for one workspace (Lambda
|
|
95
112
|
// containers cold-starting together against one EFS root), and the holder can take seconds to
|
|
96
113
|
// init plus clone/push. `randomize` de-syncs the herd so a waiter is not perpetually colliding
|
|
97
|
-
// on the same tick.
|
|
98
|
-
//
|
|
99
|
-
const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised));
|
|
114
|
+
// on the same tick. A live holder's lock never goes stale, because proper-lockfile refreshes it;
|
|
115
|
+
// it expires only when a process actually dies.
|
|
116
|
+
const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised, PROVISIONING_LOCK_STALE_MS));
|
|
100
117
|
return releaseIgnoringAlreadyReleased(release, lockPath);
|
|
101
118
|
}
|
|
102
119
|
/**
|
|
@@ -104,17 +121,61 @@ export async function acquireProvisioningLock(lockTargetDir, lockName, onComprom
|
|
|
104
121
|
* request/response cycle (a Lambda-backed API handler). `acquireProvisioningLock`'s ~600-retry
|
|
105
122
|
* budget waits minutes for a live provisioner; an admin request must fail fast instead.
|
|
106
123
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
124
|
+
* Staleness recovery is unchanged from the patient variant: a genuinely stale lock is still taken
|
|
125
|
+
* over normally. Only the RETRY loop for live contention is removed, not the staleness recovery a
|
|
126
|
+
* caller depends on -- see branch-health.ts's [H1] freshness rail, which reads this lock's mtime
|
|
127
|
+
* before an admin purge/repair proceeds.
|
|
111
128
|
*
|
|
112
129
|
* Throws with `err.code === 'ELOCKED'` on contention (a live, non-stale holder) -- callers
|
|
113
130
|
* translate that into a 409.
|
|
131
|
+
*
|
|
132
|
+
* @param staleMs how old the marker must look before this caller takes it over; defaults to
|
|
133
|
+
* {@link PROVISIONING_LOCK_STALE_MS}
|
|
134
|
+
* @param createParents whether a missing parent of `lockTargetDir` is created. A lock inside a
|
|
135
|
+
* branch passes false: a waiter must never recreate a branch root a delete removed, so a missing
|
|
136
|
+
* parent fails with ENOENT instead.
|
|
114
137
|
*/
|
|
115
|
-
export async function tryAcquireProvisioningLock(lockTargetDir, lockName, onCompromised) {
|
|
116
|
-
|
|
138
|
+
export async function tryAcquireProvisioningLock(lockTargetDir, lockName, onCompromised, staleMs = PROVISIONING_LOCK_STALE_MS, createParents = true) {
|
|
139
|
+
if (createParents) {
|
|
140
|
+
await fs.mkdir(lockTargetDir, { recursive: true });
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
await fs.mkdir(lockTargetDir).catch((err) => {
|
|
144
|
+
if (!isNodeError(err) || err.code !== 'EEXIST')
|
|
145
|
+
throw err;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
117
148
|
const lockPath = path.join(lockTargetDir, lockName);
|
|
118
|
-
const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised));
|
|
149
|
+
const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised, staleMs));
|
|
119
150
|
return releaseIgnoringAlreadyReleased(release, lockPath);
|
|
120
151
|
}
|
|
152
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
153
|
+
setTimeout(resolve, ms);
|
|
154
|
+
});
|
|
155
|
+
/**
|
|
156
|
+
* {@link tryAcquireProvisioningLock} retried for at most `waitMs`, for a caller whose own hold is
|
|
157
|
+
* milliseconds long (publishing a staged branch workspace). A dead holder's marker stays live for
|
|
158
|
+
* {@link PROVISIONING_LOCK_STALE_MS}, so waiting it out from a request would spend the request's
|
|
159
|
+
* whole timeout; this gives up instead.
|
|
160
|
+
*
|
|
161
|
+
* Retries only on `ELOCKED`, like the content-write lock's bounded wait.
|
|
162
|
+
*
|
|
163
|
+
* @throws the last `ELOCKED` error once the budget is spent
|
|
164
|
+
*/
|
|
165
|
+
export async function acquireProvisioningLockWithin(lockTargetDir, lockName, waitMs, onCompromised) {
|
|
166
|
+
const startedAt = Date.now();
|
|
167
|
+
for (let attempt = 0;; attempt++) {
|
|
168
|
+
try {
|
|
169
|
+
return await tryAcquireProvisioningLock(lockTargetDir, lockName, onCompromised);
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
if (!isNodeError(err) || err.code !== 'ELOCKED')
|
|
173
|
+
throw err;
|
|
174
|
+
const remaining = waitMs - (Date.now() - startedAt);
|
|
175
|
+
if (remaining <= 0)
|
|
176
|
+
throw err;
|
|
177
|
+
const base = Math.min(50 * 2 ** attempt, 500);
|
|
178
|
+
await sleep(Math.min(base * (0.5 + Math.random()), remaining));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
interface PhaseTotal {
|
|
2
|
+
count: number;
|
|
3
|
+
ms: number;
|
|
4
|
+
}
|
|
5
|
+
/** Record `fn` as `phase` in the active request scope; outside one, just run it. */
|
|
6
|
+
export declare function timeRequestPhase<T>(phase: string, fn: () => Promise<T>): Promise<T>;
|
|
7
|
+
/**
|
|
8
|
+
* Whether a request scope is open here.
|
|
9
|
+
* @internal Exported for tests.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isRequestTimingScopeActive(): boolean;
|
|
12
|
+
/** Name the matched route (a pattern, never raw path segments) for the summary line. */
|
|
13
|
+
export declare function setRequestTimingRoute(route: string): void;
|
|
14
|
+
/**
|
|
15
|
+
* The summary line's message, after the logger's timestamp/category prefix:
|
|
16
|
+
* `GET :branch/entries 200 1172ms | context=0 auth=12 route=1110 route>settingsRoot=1080(x2) untimed=50`.
|
|
17
|
+
* @internal Exported for tests.
|
|
18
|
+
*/
|
|
19
|
+
export declare function formatRequestTimingSummary(method: string, route: string, status: number | 'error', totalMs: number, phases: ReadonlyMap<string, PhaseTotal>): string;
|
|
20
|
+
/**
|
|
21
|
+
* Run one API request inside a timing scope and log its summary line when it settles,
|
|
22
|
+
* thrown or not. `statusOf` reads the status from the result.
|
|
23
|
+
*/
|
|
24
|
+
export declare function runWithRequestTiming<T>(method: string, fn: () => Promise<T>, statusOf: (result: T) => number): Promise<T>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request phase timing for the API handler, enabled by `CANOPYCMS_DEBUG=true`.
|
|
3
|
+
*
|
|
4
|
+
* `runWithRequestTiming` opens a request scope in AsyncLocalStorage and, when the request
|
|
5
|
+
* finishes, emits ONE summary line: method, route pattern, status, total, and every phase
|
|
6
|
+
* recorded inside the scope. `timeRequestPhase` records a phase into whichever scope is
|
|
7
|
+
* active. Scopes are per async context, so concurrent requests in one process never share
|
|
8
|
+
* or overwrite a span, and a phase reached outside any request (the worker, a build, a
|
|
9
|
+
* page render) just runs its function.
|
|
10
|
+
*
|
|
11
|
+
* Disabled, `runWithRequestTiming` calls straight through without opening a scope, so each
|
|
12
|
+
* `timeRequestPhase` costs one `getStore()` that returns undefined.
|
|
13
|
+
*
|
|
14
|
+
* A phase started inside another records under the joined name (`route>settingsRoot`), so
|
|
15
|
+
* nesting stays visible and nested time is never added to its parent's siblings. A phase
|
|
16
|
+
* run several times in one request accumulates, and the line shows the count. `untimed` is
|
|
17
|
+
* the total minus the top-level phases, which run sequentially in the handler.
|
|
18
|
+
*
|
|
19
|
+
* Server-only: `node:async_hooks`.
|
|
20
|
+
*/
|
|
21
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
22
|
+
import { createDebugLogger } from './debug.js';
|
|
23
|
+
const storage = new AsyncLocalStorage();
|
|
24
|
+
const log = createDebugLogger({ prefix: 'CanopyCMS' });
|
|
25
|
+
/** Read per call, like DebugLogger, so toggling the env var needs no restart. */
|
|
26
|
+
function isRequestTimingEnabled() {
|
|
27
|
+
return process.env.CANOPYCMS_DEBUG === 'true';
|
|
28
|
+
}
|
|
29
|
+
/** Record `fn` as `phase` in the active request scope; outside one, just run it. */
|
|
30
|
+
export async function timeRequestPhase(phase, fn) {
|
|
31
|
+
const parent = storage.getStore();
|
|
32
|
+
if (!parent)
|
|
33
|
+
return fn();
|
|
34
|
+
const key = parent.path ? `${parent.path}>${phase}` : phase;
|
|
35
|
+
// Created at START so the summary lists phases in the order they began.
|
|
36
|
+
let total = parent.phases.get(key);
|
|
37
|
+
if (!total) {
|
|
38
|
+
total = { count: 0, ms: 0 };
|
|
39
|
+
parent.phases.set(key, total);
|
|
40
|
+
}
|
|
41
|
+
const start = performance.now();
|
|
42
|
+
try {
|
|
43
|
+
return await storage.run({ ...parent, path: key }, fn);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
total.count += 1;
|
|
47
|
+
total.ms += performance.now() - start;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Whether a request scope is open here.
|
|
52
|
+
* @internal Exported for tests.
|
|
53
|
+
*/
|
|
54
|
+
export function isRequestTimingScopeActive() {
|
|
55
|
+
return storage.getStore() !== undefined;
|
|
56
|
+
}
|
|
57
|
+
/** Name the matched route (a pattern, never raw path segments) for the summary line. */
|
|
58
|
+
export function setRequestTimingRoute(route) {
|
|
59
|
+
const scope = storage.getStore();
|
|
60
|
+
if (scope)
|
|
61
|
+
scope.request.route = route;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The summary line's message, after the logger's timestamp/category prefix:
|
|
65
|
+
* `GET :branch/entries 200 1172ms | context=0 auth=12 route=1110 route>settingsRoot=1080(x2) untimed=50`.
|
|
66
|
+
* @internal Exported for tests.
|
|
67
|
+
*/
|
|
68
|
+
export function formatRequestTimingSummary(method, route, status, totalMs, phases) {
|
|
69
|
+
let topLevelMs = 0;
|
|
70
|
+
const parts = [];
|
|
71
|
+
for (const [name, { count, ms }] of phases) {
|
|
72
|
+
if (!name.includes('>'))
|
|
73
|
+
topLevelMs += ms;
|
|
74
|
+
parts.push(`${name}=${Math.round(ms)}${count > 1 ? `(x${count})` : ''}`);
|
|
75
|
+
}
|
|
76
|
+
parts.push(`untimed=${Math.max(0, Math.round(totalMs - topLevelMs))}`);
|
|
77
|
+
return `${method} ${route} ${status} ${Math.round(totalMs)}ms | ${parts.join(' ')}`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Run one API request inside a timing scope and log its summary line when it settles,
|
|
81
|
+
* thrown or not. `statusOf` reads the status from the result.
|
|
82
|
+
*/
|
|
83
|
+
export async function runWithRequestTiming(method, fn, statusOf) {
|
|
84
|
+
if (!isRequestTimingEnabled())
|
|
85
|
+
return fn();
|
|
86
|
+
const scope = {
|
|
87
|
+
phases: new Map(),
|
|
88
|
+
path: '',
|
|
89
|
+
request: { route: '(unmatched)' },
|
|
90
|
+
};
|
|
91
|
+
const start = performance.now();
|
|
92
|
+
let status = 'error';
|
|
93
|
+
try {
|
|
94
|
+
const result = await storage.run(scope, fn);
|
|
95
|
+
status = statusOf(result);
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
log.debug('timing', formatRequestTimingSummary(method, scope.request.route, status, performance.now() - start, scope.phases));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -55,36 +55,22 @@ export declare function neutralizeImplicitOffOrigin(url: string): string;
|
|
|
55
55
|
* this file either way, so the two surfaces cannot drift into disagreeing about which schemes
|
|
56
56
|
* are acceptable (that drift is exactly why `utils/url-prefix.ts` exists — see its header).
|
|
57
57
|
*
|
|
58
|
-
* `
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* a working configuration. A bad value costs a broken `<img>`.
|
|
65
|
-
* - A WRITE-side endpoint (`media.uploadUrl`, where the browser POSTs a presigned upload) must
|
|
66
|
-
* not be protocol-relative even when spelled literally, because such a URL is AMBIGUOUS: it
|
|
67
|
-
* resolves to http or https depending on the scheme of whichever editor page happens to
|
|
68
|
-
* issue the upload, so the stored config does not determine where a live presigned credential
|
|
69
|
-
* and the user's file bytes are sent. That is the same principle every other rule here
|
|
70
|
-
* enforces — a config value must describe what actually happens.
|
|
71
|
-
*
|
|
72
|
-
* Note this is NOT an argument that http is forbidden on the write side; bare `http://` is
|
|
73
|
-
* accepted, because a loopback or in-cluster S3-compatible endpoint is a real need and a
|
|
74
|
-
* browser on an https editor blocks the mixed-content request anyway, so it fails closed. The
|
|
75
|
-
* objection to `//host` is that it is undetermined, not that it might be insecure.
|
|
58
|
+
* A protocol-relative `//host` is refused even when spelled literally, because it is AMBIGUOUS:
|
|
59
|
+
* it resolves to http or https depending on the page issuing the request, so the stored config
|
|
60
|
+
* does not determine where, for `media.uploadUrl`, a live presigned credential and the user's
|
|
61
|
+
* file bytes are sent. Bare `http://` is accepted: a loopback or in-cluster S3-compatible
|
|
62
|
+
* endpoint is a real need, and a browser on an https editor blocks the mixed-content request
|
|
63
|
+
* anyway, so it fails closed.
|
|
76
64
|
*
|
|
77
65
|
* Every spelling that READS as site-relative while redefining the authority — `/\host`,
|
|
78
|
-
* `\\host`, `\/host`, `///host` — is rejected
|
|
66
|
+
* `\\host`, `\/host`, `///host` — is rejected too. Those look site-relative
|
|
79
67
|
* to a human and to a naive `startsWith('/')` check, but WHATWG URL resolves each to a
|
|
80
68
|
* different authority (measured: `///x` resolves to host `x`, not to pathname `/x`), so they
|
|
81
69
|
* are never what an adopter meant. A scheme-qualified absolute URL is a separate case and is
|
|
82
70
|
* accepted — `https:///cdn.example.com/` is unusual but unambiguous, resolving identically
|
|
83
71
|
* standalone and against any base.
|
|
84
72
|
*/
|
|
85
|
-
export declare function isHttpUrlOrSameOriginPath(value: string
|
|
86
|
-
allowProtocolRelative?: boolean;
|
|
87
|
-
}): boolean;
|
|
73
|
+
export declare function isHttpUrlOrSameOriginPath(value: string): boolean;
|
|
88
74
|
/**
|
|
89
75
|
* Sanitize an untrusted URL for use in `href` attributes.
|
|
90
76
|
*
|