@sequenceholdings/artifact-studio 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/VERSION-PIN.md +8 -1
- package/dist/api.d.ts +11 -1
- package/dist/api.js +26 -9
- package/dist/build.d.ts +2 -2
- package/dist/build.js +4 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +105 -10
- package/dist/dev-link.d.ts +1 -1
- package/dist/dev-link.js +2 -1
- package/dist/gateway/admission.d.ts +55 -0
- package/dist/gateway/admission.js +132 -0
- package/dist/gateway/control.d.ts +91 -0
- package/dist/gateway/control.js +113 -0
- package/dist/gateway/hosted-proxy.d.ts +36 -0
- package/dist/gateway/hosted-proxy.js +347 -0
- package/dist/gateway/release-admission.d.ts +23 -0
- package/dist/gateway/release-admission.js +259 -0
- package/dist/gateway/server.d.ts +29 -0
- package/dist/gateway/server.js +298 -0
- package/dist/gateway/session.d.ts +71 -0
- package/dist/gateway/session.js +143 -0
- package/dist/gateway/usage.d.ts +37 -0
- package/dist/gateway/usage.js +46 -0
- package/dist/gateway-main.d.ts +1 -0
- package/dist/gateway-main.js +84 -0
- package/dist/git-clone.d.ts +23 -1
- package/dist/git-clone.js +32 -15
- package/dist/manifest-next.d.ts +217 -0
- package/dist/manifest-next.js +120 -0
- package/dist/manifest-static.d.ts +76 -0
- package/dist/manifest-static.js +124 -0
- package/dist/manifest.d.ts +118 -13
- package/dist/manifest.js +12 -119
- package/dist/next/bridge.d.ts +47 -0
- package/dist/next/bridge.js +229 -0
- package/dist/next/browser-logs.d.ts +7 -0
- package/dist/next/browser-logs.js +51 -0
- package/dist/next/browser.d.ts +6 -0
- package/dist/next/browser.js +57 -0
- package/dist/next/protocol.d.ts +73 -0
- package/dist/next/protocol.js +56 -0
- package/dist/next/release.d.ts +15 -0
- package/dist/next/release.js +46 -0
- package/dist/next/requests.d.ts +15 -0
- package/dist/next/requests.js +59 -0
- package/dist/next/routes.d.ts +4 -0
- package/dist/next/routes.js +21 -0
- package/dist/next/streams.d.ts +16 -0
- package/dist/next/streams.js +132 -0
- package/dist/next/theme.d.ts +10 -0
- package/dist/next/theme.js +45 -0
- package/dist/next/transport.d.ts +15 -0
- package/dist/next/transport.js +13 -0
- package/dist/next-build.d.ts +29 -0
- package/dist/next-build.js +129 -0
- package/dist/next-cache/adapter-runtime.d.ts +51 -0
- package/dist/next-cache/adapter-runtime.js +73 -0
- package/dist/next-cache/component-handler.d.ts +3 -0
- package/dist/next-cache/component-handler.js +10 -0
- package/dist/next-cache/components.d.ts +20 -0
- package/dist/next-cache/components.js +113 -0
- package/dist/next-cache/config.d.ts +9 -0
- package/dist/next-cache/config.js +78 -0
- package/dist/next-cache/incremental-handler.d.ts +18 -0
- package/dist/next-cache/incremental-handler.js +17 -0
- package/dist/next-cache/incremental.d.ts +27 -0
- package/dist/next-cache/incremental.js +99 -0
- package/dist/next-cache/index.d.ts +18 -0
- package/dist/next-cache/index.js +16 -0
- package/dist/next-cache/memory-store.d.ts +27 -0
- package/dist/next-cache/memory-store.js +118 -0
- package/dist/next-cache/redis-scripts.d.ts +3 -0
- package/dist/next-cache/redis-scripts.js +85 -0
- package/dist/next-cache/redis-store.d.ts +18 -0
- package/dist/next-cache/redis-store.js +56 -0
- package/dist/next-cache/runtime.d.ts +29 -0
- package/dist/next-cache/runtime.js +63 -0
- package/dist/next-cache/serialization.d.ts +39 -0
- package/dist/next-cache/serialization.js +333 -0
- package/dist/next-cache/store.d.ts +54 -0
- package/dist/next-cache/store.js +45 -0
- package/dist/next-cache/valkey-transport.d.ts +52 -0
- package/dist/next-cache/valkey-transport.js +189 -0
- package/dist/next-dev-control.d.ts +48 -0
- package/dist/next-dev-control.js +63 -0
- package/dist/next-dev-process.d.ts +17 -0
- package/dist/next-dev-process.js +110 -0
- package/dist/next-dev.d.ts +12 -0
- package/dist/next-dev.js +267 -0
- package/dist/next-image-config.d.ts +9 -0
- package/dist/next-image-config.js +101 -0
- package/dist/next-project.d.ts +22 -0
- package/dist/next-project.js +64 -0
- package/dist/next-public-fonts-local.d.ts +7 -0
- package/dist/next-public-fonts-local.js +64 -0
- package/dist/next-public-fonts.d.ts +13 -0
- package/dist/next-public-fonts.js +49 -0
- package/dist/next-source-hash.d.ts +14 -0
- package/dist/next-source-hash.js +18 -0
- package/dist/next-source.d.ts +35 -0
- package/dist/next-source.js +155 -0
- package/dist/next.d.ts +9 -0
- package/dist/next.js +5 -0
- package/dist/project.d.ts +3 -0
- package/dist/project.js +9 -1
- package/dist/runtime-contracts.d.ts +229 -0
- package/dist/runtime-contracts.js +224 -0
- package/dist/sdk.d.ts +2 -2
- package/dist/server/broker-contract.d.ts +68 -0
- package/dist/server/broker-contract.js +33 -0
- package/dist/server/client.d.ts +67 -0
- package/dist/server/client.js +159 -0
- package/dist/server/logger.d.ts +26 -0
- package/dist/server/logger.js +24 -0
- package/dist/server/release-forms.d.ts +13 -0
- package/dist/server/release-forms.js +124 -0
- package/dist/server/request-context.d.ts +74 -0
- package/dist/server/request-context.js +103 -0
- package/dist/server/runtime-config.d.ts +53 -0
- package/dist/server/runtime-config.js +64 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +4 -0
- package/dist/templates/nextjs/.artifactignore +14 -0
- package/dist/templates/nextjs/README.md +151 -0
- package/dist/templates/nextjs/app/about/page.tsx +26 -0
- package/dist/templates/nextjs/app/actions.ts +18 -0
- package/dist/templates/nextjs/app/api/health/route.ts +6 -0
- package/dist/templates/nextjs/app/components/counter.tsx +19 -0
- package/dist/templates/nextjs/app/components/greeting-form.tsx +38 -0
- package/dist/templates/nextjs/app/globals.css +4 -0
- package/dist/templates/nextjs/app/layout.tsx +27 -0
- package/dist/templates/nextjs/app/page.tsx +51 -0
- package/dist/templates/nextjs/app/providers.tsx +107 -0
- package/dist/templates/nextjs/artifact.bundle.yml +55 -0
- package/dist/templates/nextjs/next.config.mjs +27 -0
- package/dist/templates/nextjs/package.json +32 -0
- package/dist/templates/nextjs/pnpm-workspace.yaml +8 -0
- package/dist/templates/nextjs/postcss.config.mjs +5 -0
- package/dist/templates/nextjs/tsconfig.json +20 -0
- package/dist/templates/react-vite/package.json +1 -1
- package/dist/tunnel/broker.d.ts +75 -0
- package/dist/tunnel/broker.js +451 -0
- package/dist/tunnel/client.d.ts +22 -0
- package/dist/tunnel/client.js +177 -0
- package/dist/tunnel/index.d.ts +6 -0
- package/dist/tunnel/index.js +4 -0
- package/dist/tunnel/io.d.ts +33 -0
- package/dist/tunnel/io.js +108 -0
- package/dist/tunnel/peer.d.ts +25 -0
- package/dist/tunnel/peer.js +147 -0
- package/dist/tunnel/protocol.d.ts +69 -0
- package/dist/tunnel/protocol.js +196 -0
- package/dist/tunnel/redis-ownership.d.ts +38 -0
- package/dist/tunnel/redis-ownership.js +100 -0
- package/dist/tunnel/redis-socket.d.ts +13 -0
- package/dist/tunnel/redis-socket.js +48 -0
- package/dist/tunnel/redis-types.d.ts +70 -0
- package/dist/tunnel/redis-types.js +9 -0
- package/dist/tunnel/redis.d.ts +53 -0
- package/dist/tunnel/redis.js +391 -0
- package/dist/tunnel/transport.d.ts +17 -0
- package/dist/tunnel/transport.js +1 -0
- package/package.json +67 -4
- package/templates/nextjs/.artifactignore +14 -0
- package/templates/nextjs/README.md +151 -0
- package/templates/nextjs/app/about/page.tsx +26 -0
- package/templates/nextjs/app/actions.ts +18 -0
- package/templates/nextjs/app/api/health/route.ts +6 -0
- package/templates/nextjs/app/components/counter.tsx +19 -0
- package/templates/nextjs/app/components/greeting-form.tsx +38 -0
- package/templates/nextjs/app/globals.css +4 -0
- package/templates/nextjs/app/layout.tsx +27 -0
- package/templates/nextjs/app/page.tsx +51 -0
- package/templates/nextjs/app/providers.tsx +107 -0
- package/templates/nextjs/artifact.bundle.yml +55 -0
- package/templates/nextjs/next.config.mjs +27 -0
- package/templates/nextjs/package.json +32 -0
- package/templates/nextjs/pnpm-workspace.yaml +8 -0
- package/templates/nextjs/postcss.config.mjs +5 -0
- package/templates/nextjs/tsconfig.json +20 -0
- package/templates/react-vite/package.json +1 -1
package/VERSION-PIN.md
CHANGED
|
@@ -4,12 +4,19 @@ A (re)built Artifact Studio app gets whatever `@sequenceholdings/atlas-ui` the
|
|
|
4
4
|
**building CLI** carries — not a semver the artifact's own `package.json`
|
|
5
5
|
declares.
|
|
6
6
|
|
|
7
|
-
Artifact Studio 0.2.
|
|
7
|
+
Artifact Studio 0.2.7 (Studio CLI 0.1.36) pins Atlas UI 3.3.0 exactly.
|
|
8
8
|
Ordinary builds now use its canonical, mode-aware `tokens.css`. Rebuilding an
|
|
9
9
|
existing artifact adopts this toolchain; previously uploaded immutable bundles
|
|
10
10
|
keep their original CSS. Migrate retired numbered black/white utilities to
|
|
11
11
|
semantic roles and verify a preview before activating a rebuilt artifact.
|
|
12
12
|
|
|
13
|
+
Artifact Studio 0.3.0 (Studio CLI 0.2.0) adds the opt-in Next.js runtime. The
|
|
14
|
+
static compiler continues to pin Atlas UI 3.3.0. Next.js applications own their
|
|
15
|
+
React 19 dependencies and use the `/next` client boundary that Atlas UI 3.4.0
|
|
16
|
+
introduces; they do not inherit the static compiler's aliases. The new packages must be published
|
|
17
|
+
before a fresh external Next.js scaffold can be installed. A local candidate
|
|
18
|
+
build is not evidence of registry availability.
|
|
19
|
+
|
|
13
20
|
## What this means
|
|
14
21
|
|
|
15
22
|
`shared/services/artifact-studio/src/build.ts` force-aliases platform peers via
|
package/dist/api.d.ts
CHANGED
|
@@ -2,13 +2,23 @@ export interface ApiClientOptions {
|
|
|
2
2
|
baseUrl: string;
|
|
3
3
|
token: string;
|
|
4
4
|
}
|
|
5
|
+
export declare class ArtifactApiError extends Error {
|
|
6
|
+
readonly status: number;
|
|
7
|
+
constructor(message: string, status: number);
|
|
8
|
+
}
|
|
5
9
|
export declare function getJson<T>({ baseUrl, token, path }: ApiClientOptions & {
|
|
6
10
|
path: string;
|
|
7
11
|
}): Promise<T>;
|
|
8
12
|
export declare function getJsonOr404<T>({ baseUrl, token, path }: ApiClientOptions & {
|
|
9
13
|
path: string;
|
|
10
14
|
}): Promise<T | null>;
|
|
11
|
-
export declare function postJson<T>({ baseUrl, token, path, body, }: ApiClientOptions & {
|
|
15
|
+
export declare function postJson<T>({ baseUrl, token, path, body, retry503, signal, }: ApiClientOptions & {
|
|
12
16
|
path: string;
|
|
13
17
|
body?: unknown;
|
|
18
|
+
retry503?: boolean;
|
|
19
|
+
signal?: AbortSignal;
|
|
14
20
|
}): Promise<T>;
|
|
21
|
+
export declare function deleteJson({ baseUrl, token, path, signal }: ApiClientOptions & {
|
|
22
|
+
path: string;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}): Promise<void>;
|
package/dist/api.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
1
2
|
import { authenticatedRequestUrl } from './deployment-validation.js';
|
|
3
|
+
export class ArtifactApiError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
constructor(message, status) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.name = 'ArtifactApiError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
2
11
|
const MAX_503_RETRIES = 5;
|
|
3
12
|
const DEFAULT_RETRY_AFTER_SECONDS = 2;
|
|
4
13
|
/**
|
|
@@ -36,19 +45,20 @@ function extraHeaders() {
|
|
|
36
45
|
return {};
|
|
37
46
|
}
|
|
38
47
|
}
|
|
39
|
-
async function fetchWith503Retry(input, init) {
|
|
48
|
+
async function fetchWith503Retry(input, init, maxRetries = MAX_503_RETRIES) {
|
|
40
49
|
let attempt = 0;
|
|
41
50
|
while (true) {
|
|
42
51
|
const response = await fetch(input, init);
|
|
43
|
-
if (response.status !== 503 || attempt >=
|
|
52
|
+
if (response.status !== 503 || attempt >= maxRetries)
|
|
44
53
|
return response;
|
|
45
54
|
const retryAfter = Number(response.headers.get('Retry-After')) || DEFAULT_RETRY_AFTER_SECONDS;
|
|
46
55
|
attempt += 1;
|
|
47
56
|
console.warn(`[seq-studio] backend warming up (503); retrying in ${retryAfter}s (attempt ${attempt}/${MAX_503_RETRIES})`);
|
|
48
|
-
|
|
57
|
+
// An aborted caller (e.g. artifact dev shutting down) must not wait out a Retry-After.
|
|
58
|
+
await delay(retryAfter * 1000, undefined, init.signal ? { signal: init.signal } : undefined);
|
|
49
59
|
}
|
|
50
60
|
}
|
|
51
|
-
async function authenticatedFetch({ baseUrl, init = {}, path, token, }) {
|
|
61
|
+
async function authenticatedFetch({ baseUrl, init = {}, path, token, retry503 = true, }) {
|
|
52
62
|
const url = authenticatedRequestUrl({ baseUrl, path });
|
|
53
63
|
return fetchWith503Retry(url, {
|
|
54
64
|
...init,
|
|
@@ -58,12 +68,12 @@ async function authenticatedFetch({ baseUrl, init = {}, path, token, }) {
|
|
|
58
68
|
...init.headers,
|
|
59
69
|
Authorization: `Bearer ${token}`,
|
|
60
70
|
},
|
|
61
|
-
});
|
|
71
|
+
}, retry503 ? MAX_503_RETRIES : 0);
|
|
62
72
|
}
|
|
63
73
|
export async function getJson({ baseUrl, token, path }) {
|
|
64
74
|
const response = await authenticatedFetch({ baseUrl, token, path });
|
|
65
75
|
if (!response.ok)
|
|
66
|
-
throw new
|
|
76
|
+
throw new ArtifactApiError(await responseError('GET', path, response), response.status);
|
|
67
77
|
return response.json();
|
|
68
78
|
}
|
|
69
79
|
export async function getJsonOr404({ baseUrl, token, path }) {
|
|
@@ -71,24 +81,31 @@ export async function getJsonOr404({ baseUrl, token, path }) {
|
|
|
71
81
|
if (response.status === 404)
|
|
72
82
|
return null;
|
|
73
83
|
if (!response.ok)
|
|
74
|
-
throw new
|
|
84
|
+
throw new ArtifactApiError(await responseError('GET', path, response), response.status);
|
|
75
85
|
return response.json();
|
|
76
86
|
}
|
|
77
|
-
export async function postJson({ baseUrl, token, path, body, }) {
|
|
87
|
+
export async function postJson({ baseUrl, token, path, body, retry503 = true, signal, }) {
|
|
78
88
|
const response = await authenticatedFetch({
|
|
79
89
|
baseUrl,
|
|
80
90
|
token,
|
|
81
91
|
path,
|
|
92
|
+
retry503,
|
|
82
93
|
init: {
|
|
83
94
|
method: 'POST',
|
|
84
95
|
headers: { 'Content-Type': 'application/json' },
|
|
85
96
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
97
|
+
...(signal ? { signal } : {}),
|
|
86
98
|
},
|
|
87
99
|
});
|
|
88
100
|
if (!response.ok)
|
|
89
|
-
throw new
|
|
101
|
+
throw new ArtifactApiError(await responseError('POST', path, response), response.status);
|
|
90
102
|
return response.json();
|
|
91
103
|
}
|
|
104
|
+
export async function deleteJson({ baseUrl, token, path, signal }) {
|
|
105
|
+
const response = await authenticatedFetch({ baseUrl, token, path, init: { method: 'DELETE', ...(signal ? { signal } : {}) } });
|
|
106
|
+
if (!response.ok)
|
|
107
|
+
throw new ArtifactApiError(await responseError('DELETE', path, response), response.status);
|
|
108
|
+
}
|
|
92
109
|
/**
|
|
93
110
|
* Build an actionable error message from a failed response: which request
|
|
94
111
|
* failed, with what status, and what the server said. A deploy runs several
|
package/dist/build.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ObservedBuildEvidence } from './build-provenance.js';
|
|
2
2
|
import { type ArtifactStudioSourceFile } from './project.js';
|
|
3
|
-
import type
|
|
3
|
+
import { type ArtifactStudioStaticManifest } from './manifest.js';
|
|
4
4
|
import { type ArtifactStudioPeerVersions } from './peer-versions.js';
|
|
5
5
|
/**
|
|
6
6
|
* Released Atlas UI 2.0.1 exports its canonical sheet from dist, while the
|
|
@@ -17,7 +17,7 @@ export interface ArtifactStudioRuntimeChunk {
|
|
|
17
17
|
}
|
|
18
18
|
export type ArtifactStudioBundleFormat = 'esm';
|
|
19
19
|
export interface ArtifactStudioBuildResult {
|
|
20
|
-
manifest:
|
|
20
|
+
manifest: ArtifactStudioStaticManifest;
|
|
21
21
|
files: ArtifactStudioSourceFile[];
|
|
22
22
|
/** Entry module (ESM). Lazy routes land in `chunks`. */
|
|
23
23
|
bundle: string;
|
package/dist/build.js
CHANGED
|
@@ -10,6 +10,7 @@ import { collectResolvedStylesheets } from './resolved-stylesheets.js';
|
|
|
10
10
|
import { sha256Hex } from './hash.js';
|
|
11
11
|
import { isArtifactIgnoredPath } from './artifact-ignore.js';
|
|
12
12
|
import { readArtifactStudioSource } from './project.js';
|
|
13
|
+
import { isNextArtifactManifest } from './manifest.js';
|
|
13
14
|
import { normalizeArtifactPath } from './paths.js';
|
|
14
15
|
import { resolveArtifactStudioPeerVersions, } from './peer-versions.js';
|
|
15
16
|
import { ARTIFACT_RUNTIME_CHUNK_DIR, assertSafeRuntimeChunkFileName } from './runtime-chunks.js';
|
|
@@ -103,6 +104,9 @@ export async function buildArtifactStudioProject(rootDir, { candidateTokenCompat
|
|
|
103
104
|
const selectedAtlasUiDist = resolve(atlasUiPackageDir ?? atlasUiDir, 'dist');
|
|
104
105
|
const selectedAtlasUiTokens = resolveAtlasUiTokensPath(selectedAtlasUiDir);
|
|
105
106
|
const source = await readArtifactStudioSource(rootDir);
|
|
107
|
+
if (isNextArtifactManifest(source.manifest)) {
|
|
108
|
+
throw new Error('Next.js applications are built by Atlas. Run seq-studio artifact build --env <env>.');
|
|
109
|
+
}
|
|
106
110
|
const diagnostics = [
|
|
107
111
|
...(hasUnifiedAtlasUiTokens(selectedAtlasUiDir)
|
|
108
112
|
? collectDeprecatedTokenImportDiagnostics({ files: source.files })
|
package/dist/cli.d.ts
CHANGED
package/dist/cli.js
CHANGED
|
@@ -8,21 +8,22 @@ import { writeBuildArtifact } from './build.js';
|
|
|
8
8
|
import { buildArtifactStudioProjectIsolated } from './build-subprocess.js';
|
|
9
9
|
import { resolveArtifactStudioPeerVersions, } from './peer-versions.js';
|
|
10
10
|
import { prepareArtifactBuildRoot } from './prepare-build.js';
|
|
11
|
-
import { parseArtifactStudioManifest } from './manifest.js';
|
|
12
|
-
import { hasArtifactStudioManifest, readArtifactStudioSource } from './project.js';
|
|
11
|
+
import { isNextArtifactManifest, parseArtifactStudioManifest } from './manifest.js';
|
|
12
|
+
import { hasArtifactStudioManifest, readArtifactStudioManifest, readArtifactStudioSource } from './project.js';
|
|
13
13
|
import { localGitMetadata, parseSourceSpec, resolveArtifactSource, } from './source-resolver.js';
|
|
14
14
|
import { acquireDevLock } from './dev-lock.js';
|
|
15
15
|
import { runWatchLoop } from './watch-loop.js';
|
|
16
16
|
import { hashLinkedSources, hasLinkedAtlasUiToolchain, parseLinks, rebuildLinkedDep, resolveLinkedDeps, syncLinkedDist, } from './dev-link.js';
|
|
17
17
|
import { lintArtifactSandbox, reportSandboxWarnings } from './sandbox-lint.js';
|
|
18
18
|
import { assertActiveDeployProvenance } from './active-deploy-policy.js';
|
|
19
|
+
import { normalizeArtifactPath } from './paths.js';
|
|
19
20
|
import yaml from 'js-yaml';
|
|
20
21
|
const COMMANDS = [
|
|
21
22
|
'init', 'whoami', 'link', 'env', 'status', 'list', 'show',
|
|
22
23
|
'validate', 'build', 'plan', 'deploy', 'dev', 'promote', 'pull', 'rollback',
|
|
23
24
|
];
|
|
24
25
|
const USAGE = `usage:
|
|
25
|
-
seq-studio artifact init <dir>
|
|
26
|
+
seq-studio artifact init <dir> [--template react-vite|nextjs]
|
|
26
27
|
seq-studio artifact whoami --env <env>
|
|
27
28
|
seq-studio artifact link --env <env> [--project <id>]
|
|
28
29
|
seq-studio artifact env list|use <env>
|
|
@@ -33,7 +34,7 @@ const USAGE = `usage:
|
|
|
33
34
|
seq-studio artifact build [dir | --repo <ns>/<name> | --git-url <url>] [--ref <ref>] [--out dist/artifact-bundle.json]
|
|
34
35
|
seq-studio artifact plan [dir | --repo <ns>/<name> | --git-url <url>] [--ref <ref>] --env <env>
|
|
35
36
|
seq-studio artifact deploy [dir | --repo <ns>/<name> | --git-url <url>] [--ref <ref>] --env <env> [--skip-unchanged] [--no-create] [--project <id>]
|
|
36
|
-
seq-studio artifact dev [dir] --env <env> [--once] [--key default] [--link <pkgDir>[,<pkgDir>]]
|
|
37
|
+
seq-studio artifact dev [dir] --env <env> [--once] [--key default] [--link <pkgDir>[,<pkgDir>]] [--port <port>]
|
|
37
38
|
seq-studio artifact promote <deployment-id> --env <env>
|
|
38
39
|
seq-studio artifact pull <project-id> --env <env> --out <dir>
|
|
39
40
|
seq-studio artifact rollback <deployment-id> --env <env>
|
|
@@ -57,6 +58,12 @@ const USAGE = `usage:
|
|
|
57
58
|
seq-studio login are still needed to resolve the repo and upload the
|
|
58
59
|
deployment.)
|
|
59
60
|
|
|
61
|
+
Next.js: init --template nextjs, then pnpm install with Node.js 24. dev starts
|
|
62
|
+
a local Next server and an owner-only Atlas preview tunnel (no cloud credentials).
|
|
63
|
+
build --env creates an immutable hosted preview; deploy builds and publishes
|
|
64
|
+
a reviewed Git Service revision through Atlas. For local build checks, use pnpm build.
|
|
65
|
+
--once applies to static artifacts; --port applies to Next development.
|
|
66
|
+
|
|
60
67
|
Authenticate with: seq-studio login`;
|
|
61
68
|
export async function runCli(argv = process.argv.slice(2)) {
|
|
62
69
|
if (argv.some((arg) => arg === '--token' || arg.startsWith('--token='))) {
|
|
@@ -118,17 +125,30 @@ async function initCommand(args) {
|
|
|
118
125
|
const dir = resolve(args.positional[0] ?? '.');
|
|
119
126
|
const name = dir.split('/').filter(Boolean).at(-1) ?? 'my-artifact';
|
|
120
127
|
const slug = slugify(name);
|
|
128
|
+
const template = args.flags.template ?? 'react-vite';
|
|
129
|
+
if (template !== 'react-vite' && template !== 'nextjs') {
|
|
130
|
+
throw new Error('--template must be react-vite or nextjs.');
|
|
131
|
+
}
|
|
121
132
|
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
122
|
-
const templateRoot = existsSync(resolve(moduleDir, 'templates',
|
|
123
|
-
? resolve(moduleDir, 'templates',
|
|
124
|
-
: resolve(moduleDir, '..', 'templates',
|
|
133
|
+
const templateRoot = existsSync(resolve(moduleDir, 'templates', template))
|
|
134
|
+
? resolve(moduleDir, 'templates', template)
|
|
135
|
+
: resolve(moduleDir, '..', 'templates', template);
|
|
125
136
|
await mkdir(dir, { recursive: true });
|
|
126
137
|
await cp(templateRoot, dir, { recursive: true, force: false, errorOnExist: false });
|
|
127
138
|
await replaceInFile(join(dir, 'artifact.bundle.yml'), /\{\{slug\}\}/g, slug);
|
|
128
139
|
await replaceInFile(join(dir, 'artifact.bundle.yml'), /\{\{title\}\}/g, titleize(slug));
|
|
129
140
|
await replaceInFile(join(dir, 'package.json'), /\{\{slug\}\}/g, slug);
|
|
141
|
+
if (template === 'nextjs') {
|
|
142
|
+
const peers = resolveArtifactStudioPeerVersions();
|
|
143
|
+
await replaceInFile(join(dir, 'package.json'), /\{\{artifactStudioVersion\}\}/g, peers.cliVersion);
|
|
144
|
+
for (const file of ['app/layout.tsx', 'app/page.tsx']) {
|
|
145
|
+
await replaceInFile(join(dir, file), /\{\{title\}\}/g, titleize(slug));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
130
148
|
console.log(`[seq-studio] initialized ${dir}`);
|
|
131
|
-
console.log(
|
|
149
|
+
console.log(template === 'nextjs'
|
|
150
|
+
? 'Next: cd into the app, run pnpm install, then seq-studio login && seq-studio artifact dev --env <env>. Commit pnpm-lock.yaml before deploying. No cloud credentials are required.'
|
|
151
|
+
: 'Next: seq-studio login && seq-studio artifact dev --env <env> (see: seq-studio envs list)');
|
|
132
152
|
return 0;
|
|
133
153
|
}
|
|
134
154
|
async function whoamiCommand(args) {
|
|
@@ -184,10 +204,12 @@ async function linkCommand(args) {
|
|
|
184
204
|
title: source.manifest.artifact.title,
|
|
185
205
|
slug: source.manifest.artifact.project_id,
|
|
186
206
|
description: source.manifest.artifact.description ?? null,
|
|
207
|
+
...(isNextArtifactManifest(source.manifest) ? { projectType: 'nextjs' } : {}),
|
|
187
208
|
},
|
|
188
209
|
});
|
|
189
210
|
project = created.project;
|
|
190
211
|
}
|
|
212
|
+
assertProjectRuntime({ project, artifactType: source.manifest.artifact.type, declaredSlug: source.manifest.artifact.project_id });
|
|
191
213
|
await writeLocalConfig({
|
|
192
214
|
projectId: project.id,
|
|
193
215
|
projectSlug: project.slug,
|
|
@@ -343,6 +365,12 @@ async function validateCommand(args) {
|
|
|
343
365
|
console.log(`[seq-studio] valid ${source.manifest.bundle.name}`);
|
|
344
366
|
console.log(`[seq-studio] files: ${source.files.length}`);
|
|
345
367
|
console.log(`[seq-studio] source: ${source.sourceHash}`);
|
|
368
|
+
if (isNextArtifactManifest(source.manifest)) {
|
|
369
|
+
const { validateNextProject } = await import('./next-project.js');
|
|
370
|
+
await validateNextProject({ root: dir, manifest: source.manifest, installed: false });
|
|
371
|
+
console.log('[seq-studio] Next.js source is valid; Atlas performs the release build and hosted policy checks.');
|
|
372
|
+
return 0;
|
|
373
|
+
}
|
|
346
374
|
reportSandboxWarnings(lintArtifactSandbox(source.files));
|
|
347
375
|
return 0;
|
|
348
376
|
}
|
|
@@ -372,6 +400,9 @@ async function buildCommand(args) {
|
|
|
372
400
|
: {};
|
|
373
401
|
const source = await resolveArtifactSource(spec, remote);
|
|
374
402
|
try {
|
|
403
|
+
if (isNextArtifactManifest(await readArtifactStudioManifest(source.dir))) {
|
|
404
|
+
return await buildNextCommand({ args, source, active: false });
|
|
405
|
+
}
|
|
375
406
|
const result = await buildPreparedArtifactSource(source);
|
|
376
407
|
reportBuildDiagnostics(result);
|
|
377
408
|
// A remote source's dir is a temp dir we tear down below, so default the
|
|
@@ -402,6 +433,11 @@ async function planCommand(args) {
|
|
|
402
433
|
token,
|
|
403
434
|
});
|
|
404
435
|
try {
|
|
436
|
+
const manifest = await readArtifactStudioManifest(source.dir);
|
|
437
|
+
if (isNextArtifactManifest(manifest)) {
|
|
438
|
+
const { planNextProject } = await import('./next-build.js');
|
|
439
|
+
return await planNextProject({ root: source.dir, manifest, environment: env.name });
|
|
440
|
+
}
|
|
405
441
|
const result = await buildPreparedArtifactSource(source);
|
|
406
442
|
reportBuildDiagnostics(result);
|
|
407
443
|
console.log(`[seq-studio] plan ${result.manifest.artifact.project_id} -> ${env.name}`);
|
|
@@ -458,6 +494,9 @@ async function deployCommand(args) {
|
|
|
458
494
|
// the current branch and dirty working tree.
|
|
459
495
|
if (env.name !== 'local')
|
|
460
496
|
assertActiveDeployProvenance(source.provenance);
|
|
497
|
+
if (isNextArtifactManifest(await readArtifactStudioManifest(source.dir))) {
|
|
498
|
+
return await buildNextCommand({ args, source, active: true });
|
|
499
|
+
}
|
|
461
500
|
// --skip-unchanged: hash the raw source (no build needed) and no-op when
|
|
462
501
|
// the remote active deployment already matches *and* was built with the
|
|
463
502
|
// same CLI/atlas-ui pin. Lets CI redeploy every artifact on every release
|
|
@@ -506,12 +545,56 @@ async function deployCommand(args) {
|
|
|
506
545
|
await source.cleanup();
|
|
507
546
|
}
|
|
508
547
|
}
|
|
548
|
+
async function buildNextCommand({ args, source, active }) {
|
|
549
|
+
const env = await envFromArgs(args);
|
|
550
|
+
const token = await getRequiredToken(args);
|
|
551
|
+
const manifest = await readArtifactStudioManifest(source.dir);
|
|
552
|
+
if (!isNextArtifactManifest(manifest))
|
|
553
|
+
throw new Error('Expected a Next.js application manifest.');
|
|
554
|
+
const { nextSourceRepository, resolveNextRepositoryId, submitNextBuild } = await import('./next-build.js');
|
|
555
|
+
const spec = parseSourceSpec(args);
|
|
556
|
+
const repository = nextSourceRepository({ spec, root: source.dir });
|
|
557
|
+
if (active)
|
|
558
|
+
assertActiveDeployProvenance(source.provenance);
|
|
559
|
+
const repositoryId = active || spec.kind === 'git-service'
|
|
560
|
+
? await resolveNextRepositoryId({ spec, repository, baseUrl: env.url, token })
|
|
561
|
+
: undefined;
|
|
562
|
+
const project = await ensureRemoteProject({
|
|
563
|
+
env: env.url, token, result: { manifest }, linkConfigDir: spec.kind === 'local' ? source.dir : null,
|
|
564
|
+
allowCreate: args.flags['no-create'] !== true,
|
|
565
|
+
explicitProjectId: typeof args.flags.project === 'string' ? args.flags.project : null,
|
|
566
|
+
});
|
|
567
|
+
if (args.flags['skip-unchanged']) {
|
|
568
|
+
console.log('[seq-studio] Next.js submits source to Atlas for build verification; local static-bundle skip checks do not apply.');
|
|
569
|
+
}
|
|
570
|
+
return submitNextBuild({
|
|
571
|
+
root: source.dir, manifest, projectId: project.id, baseUrl: env.url,
|
|
572
|
+
getToken: () => getRequiredToken(args), provenance: source.provenance, repository, repositoryId,
|
|
573
|
+
channel: active ? 'active' : 'preview', out: typeof args.flags.out === 'string' ? resolve(args.flags.out) : undefined,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
509
576
|
async function devCommand(args) {
|
|
510
577
|
if (args.flags.repo || args.flags['git-url']) {
|
|
511
578
|
throw new Error('dev watches a local folder; --repo / --git-url are only for build, plan, and deploy.');
|
|
512
579
|
}
|
|
513
580
|
const dir = resolve(args.positional[0] ?? '.');
|
|
514
581
|
const env = await envFromArgs(args);
|
|
582
|
+
const initialManifest = await readArtifactStudioManifest(dir);
|
|
583
|
+
if (isNextArtifactManifest(initialManifest)) {
|
|
584
|
+
if (args.flags.once)
|
|
585
|
+
throw new Error('Next.js dev is a live local session. Use artifact build --env <env> for an immutable hosted preview.');
|
|
586
|
+
const auth = await getRequiredAuth(args);
|
|
587
|
+
if (auth.authMode !== 'user')
|
|
588
|
+
throw new Error('Local Next.js development requires a user login. Run seq-studio login.');
|
|
589
|
+
const project = await ensureRemoteProject({ env: env.url, token: auth.token, result: { manifest: initialManifest }, linkConfigDir: dir });
|
|
590
|
+
const { runNextDevelopment } = await import('./next-dev.js');
|
|
591
|
+
return runNextDevelopment({
|
|
592
|
+
root: dir, manifest: initialManifest, projectId: project.id, baseUrl: env.url,
|
|
593
|
+
environment: env.name, previewKey: typeof args.flags.key === 'string' ? args.flags.key : 'default',
|
|
594
|
+
port: typeof args.flags.port === 'string' ? args.flags.port : undefined,
|
|
595
|
+
links: parseLinks(args.flags.link), getToken: () => getRequiredToken(args),
|
|
596
|
+
});
|
|
597
|
+
}
|
|
515
598
|
// Validate auth up front for a fast failure, but resolve a fresh token on
|
|
516
599
|
// every push (below) so a long-running watcher survives access-token expiry
|
|
517
600
|
// instead of dying with "Authentication failed" once the initial token's TTL
|
|
@@ -725,9 +808,9 @@ async function pullCommand(args) {
|
|
|
725
808
|
path: `/api/artifact-studio/projects/${projectId}/deployments/${projectData.project.activeDeploymentId}/files`,
|
|
726
809
|
});
|
|
727
810
|
for (const file of filesData.files) {
|
|
728
|
-
const target = join(out, file.path);
|
|
811
|
+
const target = join(out, normalizeArtifactPath(file.path));
|
|
729
812
|
await mkdir(dirname(target), { recursive: true });
|
|
730
|
-
await writeFile(target, file.content, '
|
|
813
|
+
await writeFile(target, file.encoding === 'base64' ? Buffer.from(file.content, 'base64') : file.content);
|
|
731
814
|
}
|
|
732
815
|
console.log(`[seq-studio] pulled ${projectId} to ${out}`);
|
|
733
816
|
return 0;
|
|
@@ -774,18 +857,21 @@ async function ensureRemoteProject({ env, token, result, linkConfigDir, allowCre
|
|
|
774
857
|
throw new Error(`--project ${explicitProjectId} resolves to slug "${data.project.slug}" but this source's ` +
|
|
775
858
|
`artifact.bundle.yml declares project_id "${result.manifest.artifact.project_id}" — refusing to deploy over a different project`);
|
|
776
859
|
}
|
|
860
|
+
assertProjectRuntime({ project: data.project, artifactType: result.manifest.artifact.type, declaredSlug: result.manifest.artifact.project_id });
|
|
777
861
|
return data.project;
|
|
778
862
|
}
|
|
779
863
|
const config = linkConfigDir ? await readLocalConfig(linkConfigDir) : {};
|
|
780
864
|
if (config.projectId) {
|
|
781
865
|
const data = await getJsonOr404({ baseUrl: env, token, path: `/api/artifact-studio/projects/${config.projectId}` });
|
|
782
866
|
if (data) {
|
|
867
|
+
assertProjectRuntime({ project: data.project, artifactType: result.manifest.artifact.type, declaredSlug: result.manifest.artifact.project_id });
|
|
783
868
|
return data.project;
|
|
784
869
|
}
|
|
785
870
|
console.warn(`[seq-studio] cached projectId ${config.projectId} not found on ${env} — relinking by slug`);
|
|
786
871
|
}
|
|
787
872
|
const existing = await fetchRemoteProject({ env, token, slug: result.manifest.artifact.project_id });
|
|
788
873
|
if (existing) {
|
|
874
|
+
assertProjectRuntime({ project: existing, artifactType: result.manifest.artifact.type, declaredSlug: result.manifest.artifact.project_id });
|
|
789
875
|
if (linkConfigDir)
|
|
790
876
|
await writeLocalConfig({ ...config, projectId: existing.id, projectSlug: existing.slug }, linkConfigDir);
|
|
791
877
|
return existing;
|
|
@@ -803,12 +889,21 @@ async function ensureRemoteProject({ env, token, result, linkConfigDir, allowCre
|
|
|
803
889
|
title: result.manifest.artifact.title,
|
|
804
890
|
slug: result.manifest.artifact.project_id,
|
|
805
891
|
description: result.manifest.artifact.description ?? null,
|
|
892
|
+
...(result.manifest.artifact.type === 'nextjs' ? { projectType: 'nextjs' } : {}),
|
|
806
893
|
},
|
|
807
894
|
});
|
|
808
895
|
if (linkConfigDir)
|
|
809
896
|
await writeLocalConfig({ ...config, projectId: created.project.id, projectSlug: created.project.slug }, linkConfigDir);
|
|
810
897
|
return created.project;
|
|
811
898
|
}
|
|
899
|
+
function assertProjectRuntime({ project, artifactType, declaredSlug }) {
|
|
900
|
+
if (artifactType === 'nextjs' && project.slug !== declaredSlug) {
|
|
901
|
+
throw new Error(`Linked project ${project.slug} does not match artifact.bundle.yml project_id ${declaredSlug}. Relink to the intended project.`);
|
|
902
|
+
}
|
|
903
|
+
if (artifactType === 'nextjs' && project.projectType && project.projectType !== 'nextjs') {
|
|
904
|
+
throw new Error(`Project ${project.slug} uses the static runtime. Create a separate Next.js project in Atlas and update artifact.bundle.yml project_id before linking.`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
812
907
|
async function uploadDeployment({ env, token, projectId, result, channel, previewKey, linked = false, provenance, message, }) {
|
|
813
908
|
// Active deploys omit `version`: the server derives it from its own
|
|
814
909
|
// sourceHash and de-dupes against the (projectId, version) unique
|
package/dist/dev-link.d.ts
CHANGED
|
@@ -53,7 +53,7 @@ export declare function resolveLinkedDeps({ links, cwd, resolver, readPackageNam
|
|
|
53
53
|
*/
|
|
54
54
|
export declare function hashLinkedSources(deps: readonly LinkedDep[]): Promise<string>;
|
|
55
55
|
/** Run a linked package's `build` script in a child process. */
|
|
56
|
-
export declare function rebuildLinkedDep(dep: LinkedDep, signal?: AbortSignal): Promise<void>;
|
|
56
|
+
export declare function rebuildLinkedDep(dep: LinkedDep, signal?: AbortSignal, env?: NodeJS.ProcessEnv): Promise<void>;
|
|
57
57
|
/**
|
|
58
58
|
* Copy a linked package's freshly built `dist` and root CSS entries into the
|
|
59
59
|
* location the artifact build resolves them from. A no-op when the artifact
|
package/dist/dev-link.js
CHANGED
|
@@ -135,7 +135,7 @@ async function collectFiles(root) {
|
|
|
135
135
|
return out;
|
|
136
136
|
}
|
|
137
137
|
/** Run a linked package's `build` script in a child process. */
|
|
138
|
-
export function rebuildLinkedDep(dep, signal) {
|
|
138
|
+
export function rebuildLinkedDep(dep, signal, env) {
|
|
139
139
|
return new Promise((resolveBuild, reject) => {
|
|
140
140
|
if (signal?.aborted) {
|
|
141
141
|
reject(new Error('build aborted'));
|
|
@@ -143,6 +143,7 @@ export function rebuildLinkedDep(dep, signal) {
|
|
|
143
143
|
}
|
|
144
144
|
const child = spawn('pnpm', ['run', 'build'], {
|
|
145
145
|
cwd: dep.dir,
|
|
146
|
+
...(env ? { env } : {}),
|
|
146
147
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
147
148
|
});
|
|
148
149
|
const onAbort = () => {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { EventEmitter } from 'node:events';
|
|
2
|
+
export interface AdmissionRedis {
|
|
3
|
+
readonly options: {
|
|
4
|
+
enableOfflineQueue?: boolean;
|
|
5
|
+
autoResendUnfulfilledCommands?: boolean;
|
|
6
|
+
maxRetriesPerRequest?: number | null;
|
|
7
|
+
};
|
|
8
|
+
eval(script: string, numberOfKeys: number, ...parameters: (string | number)[]): Promise<unknown>;
|
|
9
|
+
}
|
|
10
|
+
export interface AdmissionTarget {
|
|
11
|
+
environment: string;
|
|
12
|
+
projectId: string;
|
|
13
|
+
deploymentId: string;
|
|
14
|
+
resources?: {
|
|
15
|
+
concurrency?: number;
|
|
16
|
+
max_instances?: number;
|
|
17
|
+
timeout_seconds?: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
type ResponseLifecycle = Pick<EventEmitter, 'once' | 'off'> & {
|
|
21
|
+
writableFinished?: boolean;
|
|
22
|
+
destroyed?: boolean;
|
|
23
|
+
};
|
|
24
|
+
export interface AdmissionLease {
|
|
25
|
+
requestId: string;
|
|
26
|
+
/** Redis-server deadline, for tracing; local cancellation uses elapsed time. */
|
|
27
|
+
deadline: number;
|
|
28
|
+
timeoutMs: number;
|
|
29
|
+
release(): Promise<void>;
|
|
30
|
+
/** Register immediately after admission, before starting upstream work.
|
|
31
|
+
* onTimeout must synchronously abort the upstream before the permit is freed. */
|
|
32
|
+
bind(response: ResponseLifecycle, onTimeout: () => void): () => void;
|
|
33
|
+
}
|
|
34
|
+
export declare class ArtifactAdmissionError extends Error {
|
|
35
|
+
readonly status: 429 | 503;
|
|
36
|
+
readonly code: 'capacity_exceeded' | 'admission_unavailable';
|
|
37
|
+
constructor(status: 429 | 503, code: 'capacity_exceeded' | 'admission_unavailable');
|
|
38
|
+
}
|
|
39
|
+
/** Fail-closed admission with no command retries. An uncertain acquisition is
|
|
40
|
+
* never used; its unique permit expires even if Redis accepted it before loss. */
|
|
41
|
+
export declare class ArtifactRequestAdmission {
|
|
42
|
+
private readonly options;
|
|
43
|
+
private readonly namespace;
|
|
44
|
+
private readonly ceiling;
|
|
45
|
+
constructor(options: {
|
|
46
|
+
redis: AdmissionRedis;
|
|
47
|
+
namespace: string;
|
|
48
|
+
platformCeiling?: number;
|
|
49
|
+
onError?: (event: {
|
|
50
|
+
kind: 'admission_release_failed' | 'admission_timeout_callback_failed';
|
|
51
|
+
}) => void;
|
|
52
|
+
});
|
|
53
|
+
acquire(target: AdmissionTarget): Promise<AdmissionLease>;
|
|
54
|
+
}
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { artifactNextResourceLimitsSchema } from '../manifest-next.js';
|
|
5
|
+
import { redisNamespaceSchema } from '../tunnel/redis-ownership.js';
|
|
6
|
+
export class ArtifactAdmissionError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
code;
|
|
9
|
+
constructor(status, code) {
|
|
10
|
+
super(status === 429 ? 'Application request capacity exceeded' : 'Application request admission unavailable');
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.name = 'ArtifactAdmissionError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// The tunnel's APP_ENV namespace format, so both Redis key spaces accept the same deployment identities.
|
|
17
|
+
const namespace = redisNamespaceSchema;
|
|
18
|
+
const identifier = z.string().min(1).max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/);
|
|
19
|
+
const resourceFields = artifactNextResourceLimitsSchema.unwrap().shape;
|
|
20
|
+
const resources = z.object({
|
|
21
|
+
concurrency: resourceFields.concurrency,
|
|
22
|
+
max_instances: resourceFields.max_instances,
|
|
23
|
+
timeout_seconds: resourceFields.timeout_seconds,
|
|
24
|
+
});
|
|
25
|
+
// Both keys have the same Redis Cluster hash tag. A lease can never exist in
|
|
26
|
+
// only one set, and previews/retained releases consume the same application cap.
|
|
27
|
+
const ACQUIRE = `-- artifact-request-admission-acquire-v1
|
|
28
|
+
local time = redis.call('TIME')
|
|
29
|
+
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
30
|
+
local maximum = tonumber(ARGV[2])
|
|
31
|
+
local deadline = now + tonumber(ARGV[3])
|
|
32
|
+
for _, key in ipairs(KEYS) do
|
|
33
|
+
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
|
|
34
|
+
if redis.call('ZCARD', key) >= maximum then return {0, now} end
|
|
35
|
+
end
|
|
36
|
+
for _, key in ipairs(KEYS) do
|
|
37
|
+
redis.call('ZADD', key, deadline, ARGV[1])
|
|
38
|
+
local latest = redis.call('ZREVRANGE', key, 0, 0, 'WITHSCORES')
|
|
39
|
+
redis.call('PEXPIRE', key, tonumber(latest[2]) - now + 1000)
|
|
40
|
+
end
|
|
41
|
+
return {1, deadline}`;
|
|
42
|
+
const RELEASE = `-- artifact-request-admission-release-v1
|
|
43
|
+
for _, key in ipairs(KEYS) do
|
|
44
|
+
redis.call('ZREM', key, ARGV[1])
|
|
45
|
+
if redis.call('ZCARD', key) == 0 then redis.call('DEL', key) end
|
|
46
|
+
end
|
|
47
|
+
return 1`;
|
|
48
|
+
/** Fail-closed admission with no command retries. An uncertain acquisition is
|
|
49
|
+
* never used; its unique permit expires even if Redis accepted it before loss. */
|
|
50
|
+
export class ArtifactRequestAdmission {
|
|
51
|
+
options;
|
|
52
|
+
namespace;
|
|
53
|
+
ceiling;
|
|
54
|
+
constructor(options) {
|
|
55
|
+
this.options = options;
|
|
56
|
+
this.namespace = namespace.parse(options.namespace);
|
|
57
|
+
this.ceiling = z.number().int().min(1).max(256).parse(options.platformCeiling ?? 256);
|
|
58
|
+
const policy = options.redis.options;
|
|
59
|
+
if (policy.enableOfflineQueue !== false || policy.autoResendUnfulfilledCommands !== false || policy.maxRetriesPerRequest !== 0) {
|
|
60
|
+
throw new Error('Admission Redis must disable offline queues, command replay and retries');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async acquire(target) {
|
|
64
|
+
if (namespace.parse(target.environment) !== this.namespace)
|
|
65
|
+
throw new Error('Admission environment mismatch');
|
|
66
|
+
const projectId = identifier.parse(target.projectId), deploymentId = identifier.parse(target.deploymentId);
|
|
67
|
+
const limits = resources.parse(target.resources ?? {});
|
|
68
|
+
const maximum = Math.min(limits.concurrency * limits.max_instances, this.ceiling);
|
|
69
|
+
const timeoutMs = limits.timeout_seconds * 1000;
|
|
70
|
+
const requestId = randomUUID();
|
|
71
|
+
const prefix = `artifact-admission:${this.namespace}:{${this.namespace}:${projectId}}`;
|
|
72
|
+
const keys = [`${prefix}:app`, `${prefix}:deployment:${deploymentId}`];
|
|
73
|
+
const began = performance.now();
|
|
74
|
+
let result;
|
|
75
|
+
try {
|
|
76
|
+
result = z.tuple([z.number().int().min(0).max(1), z.number().int().positive()]).parse(await this.options.redis.eval(ACQUIRE, 2, ...keys, requestId, maximum, timeoutMs));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new ArtifactAdmissionError(503, 'admission_unavailable');
|
|
80
|
+
}
|
|
81
|
+
if (result[0] === 0)
|
|
82
|
+
throw new ArtifactAdmissionError(429, 'capacity_exceeded');
|
|
83
|
+
// Never start work under an admission whose local request budget already
|
|
84
|
+
// elapsed while Redis was unavailable or stalled. Redis TTL reclaims it.
|
|
85
|
+
if (performance.now() - began >= timeoutMs)
|
|
86
|
+
throw new ArtifactAdmissionError(503, 'admission_unavailable');
|
|
87
|
+
let releasePromise;
|
|
88
|
+
let cleanup;
|
|
89
|
+
let bound = false;
|
|
90
|
+
const release = () => {
|
|
91
|
+
cleanup?.();
|
|
92
|
+
// Repeated finish/close/error notifications must never free another
|
|
93
|
+
// request's permit, nor cause a failed command to be retried.
|
|
94
|
+
releasePromise ??= this.options.redis.eval(RELEASE, 2, ...keys, requestId).then(() => undefined, () => { throw new ArtifactAdmissionError(503, 'admission_unavailable'); });
|
|
95
|
+
return releasePromise;
|
|
96
|
+
};
|
|
97
|
+
const releaseQuietly = () => {
|
|
98
|
+
void release().catch(() => this.options.onError?.({ kind: 'admission_release_failed' }));
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
requestId, deadline: result[1], timeoutMs, release,
|
|
102
|
+
bind: (response, onTimeout) => {
|
|
103
|
+
if (bound || releasePromise)
|
|
104
|
+
throw new Error('Admission lease already bound or released');
|
|
105
|
+
bound = true;
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
try {
|
|
108
|
+
onTimeout();
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
this.options.onError?.({ kind: 'admission_timeout_callback_failed' });
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
releaseQuietly();
|
|
115
|
+
}
|
|
116
|
+
}, Math.max(0, timeoutMs - (performance.now() - began)));
|
|
117
|
+
timer.unref();
|
|
118
|
+
cleanup = () => {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
response.off('finish', releaseQuietly);
|
|
121
|
+
response.off('close', releaseQuietly);
|
|
122
|
+
};
|
|
123
|
+
response.once('finish', releaseQuietly);
|
|
124
|
+
response.once('close', releaseQuietly);
|
|
125
|
+
if (response.writableFinished || response.destroyed)
|
|
126
|
+
releaseQuietly();
|
|
127
|
+
// Detaching also releases; callers cannot strand an admitted permit.
|
|
128
|
+
return releaseQuietly;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|