@sequenceholdings/artifact-studio 0.2.5 → 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/README.md +35 -0
- package/VERSION-PIN.md +14 -3
- package/dist/api.d.ts +11 -1
- package/dist/api.js +26 -9
- package/dist/atlas-ui-css-plugin.d.ts +11 -7
- package/dist/atlas-ui-css-plugin.js +38 -10
- package/dist/build.d.ts +2 -2
- package/dist/build.js +14 -6
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +108 -37
- 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 +124 -19
- package/dist/manifest.js +12 -118
- 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 +420 -1
- package/dist/sdk.js +35 -0
- 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/artifact.bundle.yml +0 -1
- 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 +68 -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/artifact.bundle.yml +0 -1
- package/templates/react-vite/package.json +1 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type CacheCommit, type CacheLocation, type CacheLookup, type NextCacheNamespace, type NextCacheStore } from './store.js';
|
|
2
|
+
import type { RedisScriptExecutor } from './valkey-transport.js';
|
|
3
|
+
/** Shared Redis/Valkey store. No process-local invalidation state or fallback. */
|
|
4
|
+
export declare class RedisNextCacheStore implements NextCacheStore {
|
|
5
|
+
private readonly executor;
|
|
6
|
+
constructor({ executor }: {
|
|
7
|
+
executor: RedisScriptExecutor;
|
|
8
|
+
});
|
|
9
|
+
read(input: CacheLocation & {
|
|
10
|
+
tags: string[];
|
|
11
|
+
}): Promise<CacheLookup>;
|
|
12
|
+
commit(input: CacheCommit): Promise<boolean>;
|
|
13
|
+
invalidate(input: {
|
|
14
|
+
namespace: NextCacheNamespace;
|
|
15
|
+
tags: string[];
|
|
16
|
+
expireAfterMs?: number;
|
|
17
|
+
}): Promise<void>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { cacheDigest, cacheEntryKey, cacheNamespacePrefix, MAX_CACHE_ENTRY_AGE_MS, MAX_CACHE_WRITE_AGE_MS, MAX_NAMESPACE_TAGS, normalizeCacheTags, validateCacheCommit, validateExpiration, } from './store.js';
|
|
4
|
+
import { NEXT_CACHE_COMMIT_SCRIPT, NEXT_CACHE_INVALIDATE_SCRIPT, NEXT_CACHE_READ_SCRIPT } from './redis-scripts.js';
|
|
5
|
+
const lookupSchema = z.object({
|
|
6
|
+
ticket: z.object({
|
|
7
|
+
incarnation: z.string().uuid(), epoch: z.number().int().nonnegative(),
|
|
8
|
+
version: z.string().max(36), startedAt: z.number().int().nonnegative(),
|
|
9
|
+
}),
|
|
10
|
+
entry: z.object({ payload: z.string(), stale: z.boolean() }).optional(),
|
|
11
|
+
});
|
|
12
|
+
const METADATA_RETENTION_MS = MAX_CACHE_ENTRY_AGE_MS + MAX_CACHE_WRITE_AGE_MS;
|
|
13
|
+
/** Shared Redis/Valkey store. No process-local invalidation state or fallback. */
|
|
14
|
+
export class RedisNextCacheStore {
|
|
15
|
+
executor;
|
|
16
|
+
constructor({ executor }) {
|
|
17
|
+
this.executor = executor;
|
|
18
|
+
}
|
|
19
|
+
async read(input) {
|
|
20
|
+
const tags = normalizeCacheTags(input.tags).map(cacheDigest);
|
|
21
|
+
const result = await this.executor.eval({
|
|
22
|
+
script: NEXT_CACHE_READ_SCRIPT,
|
|
23
|
+
keys: [`${cacheNamespacePrefix(input.namespace)}:meta`, cacheEntryKey(input)],
|
|
24
|
+
arguments: [randomUUID(), String(METADATA_RETENTION_MS), JSON.stringify(tags)],
|
|
25
|
+
});
|
|
26
|
+
if (typeof result !== 'string')
|
|
27
|
+
throw new Error('Invalid Next cache response');
|
|
28
|
+
return lookupSchema.parse(JSON.parse(result));
|
|
29
|
+
}
|
|
30
|
+
async commit(input) {
|
|
31
|
+
validateCacheCommit(input);
|
|
32
|
+
const result = await this.executor.eval({
|
|
33
|
+
script: NEXT_CACHE_COMMIT_SCRIPT,
|
|
34
|
+
keys: [`${cacheNamespacePrefix(input.namespace)}:meta`, cacheEntryKey(input)],
|
|
35
|
+
arguments: [JSON.stringify(input.ticket), randomUUID(), JSON.stringify(input.tags.map(cacheDigest)),
|
|
36
|
+
input.payload, String(input.ttlMs), String(METADATA_RETENTION_MS), String(MAX_CACHE_WRITE_AGE_MS)],
|
|
37
|
+
});
|
|
38
|
+
if (result !== 0 && result !== 1)
|
|
39
|
+
throw new Error('Invalid Next cache commit response');
|
|
40
|
+
return result === 1;
|
|
41
|
+
}
|
|
42
|
+
async invalidate(input) {
|
|
43
|
+
validateExpiration(input.expireAfterMs);
|
|
44
|
+
const tags = normalizeCacheTags(input.tags).map(cacheDigest);
|
|
45
|
+
if (!tags.length)
|
|
46
|
+
return;
|
|
47
|
+
const result = await this.executor.eval({
|
|
48
|
+
script: NEXT_CACHE_INVALIDATE_SCRIPT,
|
|
49
|
+
keys: [`${cacheNamespacePrefix(input.namespace)}:meta`],
|
|
50
|
+
arguments: [randomUUID(), String(METADATA_RETENTION_MS), JSON.stringify(tags),
|
|
51
|
+
String(input.expireAfterMs ?? 0), String(MAX_NAMESPACE_TAGS)],
|
|
52
|
+
});
|
|
53
|
+
if (result !== 1)
|
|
54
|
+
throw new Error('Next cache invalidation failed');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const artifactNextCacheBindingSchema: z.ZodObject<{
|
|
3
|
+
namespace: z.ZodObject<{
|
|
4
|
+
applicationId: z.ZodString;
|
|
5
|
+
environment: z.ZodString;
|
|
6
|
+
releaseId: z.ZodString;
|
|
7
|
+
generation: z.ZodString;
|
|
8
|
+
}, z.core.$strict>;
|
|
9
|
+
connection: z.ZodObject<{
|
|
10
|
+
host: z.ZodString;
|
|
11
|
+
port: z.ZodNumber;
|
|
12
|
+
serverIdentity: z.ZodString;
|
|
13
|
+
caCertificates: z.ZodArray<z.ZodString>;
|
|
14
|
+
}, z.core.$strict>;
|
|
15
|
+
}, z.core.$strict>;
|
|
16
|
+
export declare function createArtifactNextRuntimeCache({ env }?: {
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
}): {
|
|
19
|
+
IncrementalCacheHandler: import("./incremental.js").NextIncrementalCacheHandlerConstructor;
|
|
20
|
+
cacheComponents: import("./components.js").NextComponentCacheHandler;
|
|
21
|
+
verifyReadiness(): Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
export declare function getArtifactNextRuntimeCache(): {
|
|
24
|
+
IncrementalCacheHandler: import("./incremental.js").NextIncrementalCacheHandlerConstructor;
|
|
25
|
+
cacheComponents: import("./components.js").NextComponentCacheHandler;
|
|
26
|
+
verifyReadiness(): Promise<void>;
|
|
27
|
+
};
|
|
28
|
+
/** Trusted startup calls this before serving; unlike cache reads, failures propagate. */
|
|
29
|
+
export declare function verifyArtifactNextCacheReadiness(): Promise<void>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { createComponentCacheHandler } from './components.js';
|
|
4
|
+
import { createIncrementalCacheHandler } from './incremental.js';
|
|
5
|
+
import { LocalNextCacheStore } from './memory-store.js';
|
|
6
|
+
import { RedisNextCacheStore } from './redis-store.js';
|
|
7
|
+
import { ValkeyScriptExecutor } from './valkey-transport.js';
|
|
8
|
+
const namespaceSchema = z.strictObject({
|
|
9
|
+
applicationId: z.string().min(1).max(256), environment: z.string().min(1).max(256),
|
|
10
|
+
releaseId: z.string().min(1).max(256), generation: z.string().min(1).max(256),
|
|
11
|
+
});
|
|
12
|
+
export const artifactNextCacheBindingSchema = z.strictObject({
|
|
13
|
+
namespace: namespaceSchema,
|
|
14
|
+
connection: z.strictObject({
|
|
15
|
+
host: z.string().min(1).max(253), port: z.number().int().min(1).max(65535),
|
|
16
|
+
serverIdentity: z.string().min(1).max(253), caCertificates: z.array(z.string().min(1).max(16384)).min(1).max(16),
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
function disabledStore() {
|
|
20
|
+
return {
|
|
21
|
+
async read() { return { ticket: { incarnation: randomUUID(), epoch: 0, version: '', startedAt: Date.now() } }; },
|
|
22
|
+
async commit() { return false; },
|
|
23
|
+
async invalidate() { },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function createArtifactNextRuntimeCache({ env = process.env } = {}) {
|
|
27
|
+
let store;
|
|
28
|
+
let namespace;
|
|
29
|
+
const mode = env.ARTIFACT_NEXT_CACHE_MODE;
|
|
30
|
+
if (mode === 'hosted') {
|
|
31
|
+
const binding = artifactNextCacheBindingSchema.parse(JSON.parse(env.ARTIFACT_NEXT_CACHE_BINDING ?? 'null'));
|
|
32
|
+
store = new RedisNextCacheStore({ executor: new ValkeyScriptExecutor({ connection: binding.connection }) });
|
|
33
|
+
namespace = binding.namespace;
|
|
34
|
+
}
|
|
35
|
+
else if (mode === 'local' || mode === 'build' || mode === 'disabled') {
|
|
36
|
+
if (env.ARTIFACT_NEXT_CACHE_BINDING)
|
|
37
|
+
throw new Error('Only hosted cache mode may receive a private binding');
|
|
38
|
+
if (env.K_SERVICE && mode !== 'disabled')
|
|
39
|
+
throw new Error('Hosted Next runtimes cannot use a process-local cache');
|
|
40
|
+
store = mode === 'disabled' ? disabledStore() : new LocalNextCacheStore({ purpose: 'local-development' });
|
|
41
|
+
namespace = { applicationId: 'disposable', environment: mode, releaseId: 'local', generation: randomUUID() };
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
throw new Error('Artifact Next cache mode must be explicitly configured before starting the server');
|
|
45
|
+
}
|
|
46
|
+
const options = { store, namespace };
|
|
47
|
+
return {
|
|
48
|
+
IncrementalCacheHandler: createIncrementalCacheHandler(options), cacheComponents: createComponentCacheHandler(options),
|
|
49
|
+
async verifyReadiness() {
|
|
50
|
+
if (mode === 'hosted')
|
|
51
|
+
await store.read({ namespace, key: '__platform_readiness', tags: [] });
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
let handlers;
|
|
56
|
+
export function getArtifactNextRuntimeCache() {
|
|
57
|
+
handlers ??= createArtifactNextRuntimeCache();
|
|
58
|
+
return handlers;
|
|
59
|
+
}
|
|
60
|
+
/** Trusted startup calls this before serving; unlike cache reads, failures propagate. */
|
|
61
|
+
export async function verifyArtifactNextCacheReadiness() {
|
|
62
|
+
await getArtifactNextRuntimeCache().verifyReadiness();
|
|
63
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
export interface CacheSerializationLimits {
|
|
3
|
+
/** Maximum UTF-8 bytes in the complete serialized record, including tags. */
|
|
4
|
+
maxBytes: number;
|
|
5
|
+
/** Maximum nesting depth; the root value has depth zero. */
|
|
6
|
+
maxDepth: number;
|
|
7
|
+
/** Maximum cumulative value count, including map keys and values. */
|
|
8
|
+
maxMembers: number;
|
|
9
|
+
}
|
|
10
|
+
export declare const DEFAULT_CACHE_SERIALIZATION_LIMITS: Readonly<CacheSerializationLimits>;
|
|
11
|
+
type SerializationErrorCode = 'invalid_value' | 'limit_exceeded' | 'invalid_payload' | 'stream_timeout' | 'stream_aborted';
|
|
12
|
+
export declare class CacheSerializationError extends Error {
|
|
13
|
+
readonly code: SerializationErrorCode;
|
|
14
|
+
constructor({ code, message }: {
|
|
15
|
+
code: SerializationErrorCode;
|
|
16
|
+
message: string;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** Encodes only inert values, without invoking toJSON, getters or object constructors. */
|
|
20
|
+
export declare function serializeCacheValue({ value, limits }: {
|
|
21
|
+
value: unknown;
|
|
22
|
+
limits?: Partial<CacheSerializationLimits>;
|
|
23
|
+
}): string;
|
|
24
|
+
export declare function deserializeCacheValue({ serialized, limits }: {
|
|
25
|
+
serialized: string;
|
|
26
|
+
limits?: Partial<CacheSerializationLimits>;
|
|
27
|
+
}): unknown;
|
|
28
|
+
/** Consumes an owned stream. Callers must pass a cache branch, not a response's live body. */
|
|
29
|
+
export declare function readCacheStream({ stream, maxBytes, timeoutMs, maxChunks, signal, }: {
|
|
30
|
+
stream: ReadableStream<Uint8Array>;
|
|
31
|
+
maxBytes?: number;
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
maxChunks?: number;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
}): Promise<Buffer>;
|
|
36
|
+
export declare function createCacheStream({ bytes }: {
|
|
37
|
+
bytes: Uint8Array;
|
|
38
|
+
}): ReadableStream<Uint8Array>;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
export const DEFAULT_CACHE_SERIALIZATION_LIMITS = Object.freeze({
|
|
4
|
+
maxBytes: 16 * 1024 * 1024,
|
|
5
|
+
maxDepth: 64,
|
|
6
|
+
maxMembers: 100_000,
|
|
7
|
+
});
|
|
8
|
+
export class CacheSerializationError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor({ code, message }) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'CacheSerializationError';
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function fail({ code, message }) {
|
|
17
|
+
throw new CacheSerializationError({ code, message });
|
|
18
|
+
}
|
|
19
|
+
function positiveInteger({ value, name }) {
|
|
20
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
21
|
+
fail({ code: 'invalid_value', message: `${name} must be a positive safe integer` });
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function resolveLimits({ limits }) {
|
|
26
|
+
const resolved = { ...DEFAULT_CACHE_SERIALIZATION_LIMITS, ...limits };
|
|
27
|
+
for (const [name, value] of Object.entries(resolved))
|
|
28
|
+
positiveInteger({ value, name });
|
|
29
|
+
// Even a caller-supplied limit must not allow unbounded JavaScript recursion.
|
|
30
|
+
if (resolved.maxDepth > 128)
|
|
31
|
+
fail({ code: 'invalid_value', message: 'maxDepth cannot exceed 128' });
|
|
32
|
+
return resolved;
|
|
33
|
+
}
|
|
34
|
+
class ValueBudget {
|
|
35
|
+
limits;
|
|
36
|
+
members = 0;
|
|
37
|
+
constructor(limits) {
|
|
38
|
+
this.limits = limits;
|
|
39
|
+
}
|
|
40
|
+
visit({ depth }) {
|
|
41
|
+
if (depth > this.limits.maxDepth)
|
|
42
|
+
fail({ code: 'limit_exceeded', message: 'Cache value depth limit exceeded' });
|
|
43
|
+
this.members += 1;
|
|
44
|
+
this.checkMembers({ additional: 0 });
|
|
45
|
+
}
|
|
46
|
+
checkMembers({ additional }) {
|
|
47
|
+
if (additional > this.limits.maxMembers - this.members) {
|
|
48
|
+
fail({ code: 'limit_exceeded', message: 'Cache value member limit exceeded' });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
|
53
|
+
function checkKey({ key, code }) {
|
|
54
|
+
if (UNSAFE_KEYS.has(key))
|
|
55
|
+
fail({ code, message: 'Unsafe cache record key' });
|
|
56
|
+
}
|
|
57
|
+
function numberPayload({ value }) {
|
|
58
|
+
if (Object.is(value, -0))
|
|
59
|
+
return '-0';
|
|
60
|
+
return Number.isFinite(value) ? value : String(value);
|
|
61
|
+
}
|
|
62
|
+
class ValueWriter extends ValueBudget {
|
|
63
|
+
bytes = 0;
|
|
64
|
+
parts = [];
|
|
65
|
+
ancestors = new Set();
|
|
66
|
+
write({ text }) {
|
|
67
|
+
this.bytes += Buffer.byteLength(text);
|
|
68
|
+
if (this.bytes > this.limits.maxBytes)
|
|
69
|
+
fail({ code: 'limit_exceeded', message: 'Cache value byte limit exceeded' });
|
|
70
|
+
this.parts.push(text);
|
|
71
|
+
}
|
|
72
|
+
writeString({ value }) {
|
|
73
|
+
if (Buffer.byteLength(value) > this.limits.maxBytes - this.bytes) {
|
|
74
|
+
fail({ code: 'limit_exceeded', message: 'Cache value byte limit exceeded' });
|
|
75
|
+
}
|
|
76
|
+
this.write({ text: JSON.stringify(value) });
|
|
77
|
+
}
|
|
78
|
+
encode({ value, depth }) {
|
|
79
|
+
this.visit({ depth });
|
|
80
|
+
if (value === null || value === undefined) {
|
|
81
|
+
this.write({ text: value === null ? '["null"]' : '["undefined"]' });
|
|
82
|
+
}
|
|
83
|
+
else if (typeof value === 'string') {
|
|
84
|
+
this.write({ text: '["string",' });
|
|
85
|
+
this.writeString({ value });
|
|
86
|
+
this.write({ text: ']' });
|
|
87
|
+
}
|
|
88
|
+
else if (typeof value === 'boolean' || typeof value === 'number') {
|
|
89
|
+
const payload = typeof value === 'number' ? numberPayload({ value }) : value;
|
|
90
|
+
this.write({ text: `["${typeof value}",${JSON.stringify(payload)}]` });
|
|
91
|
+
}
|
|
92
|
+
else if (typeof value === 'object') {
|
|
93
|
+
this.encodeObject({ value, depth });
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
fail({ code: 'invalid_value', message: 'Unsupported cache value type' });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
encodeObject({ value, depth }) {
|
|
100
|
+
if (this.ancestors.has(value))
|
|
101
|
+
fail({ code: 'invalid_value', message: 'Cyclic cache value' });
|
|
102
|
+
this.ancestors.add(value);
|
|
103
|
+
try {
|
|
104
|
+
const prototype = Object.getPrototypeOf(value);
|
|
105
|
+
if (Buffer.isBuffer(value) && prototype === Buffer.prototype)
|
|
106
|
+
this.encodeBytes({ value, tag: 'buffer' });
|
|
107
|
+
else if (value instanceof Uint8Array && prototype === Uint8Array.prototype)
|
|
108
|
+
this.encodeBytes({ value, tag: 'bytes' });
|
|
109
|
+
else if (value instanceof Map && prototype === Map.prototype)
|
|
110
|
+
this.encodeMap({ value, depth });
|
|
111
|
+
else if (Array.isArray(value) && prototype === Array.prototype)
|
|
112
|
+
this.encodeArray({ value, depth });
|
|
113
|
+
else if (prototype === Object.prototype || prototype === null)
|
|
114
|
+
this.encodeRecord({ value, depth, nullPrototype: prototype === null });
|
|
115
|
+
else
|
|
116
|
+
fail({ code: 'invalid_value', message: 'Unsupported cache value prototype' });
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
this.ancestors.delete(value);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
encodeBytes({ value, tag }) {
|
|
123
|
+
if (Math.ceil(value.byteLength / 3) * 4 > this.limits.maxBytes - this.bytes) {
|
|
124
|
+
fail({ code: 'limit_exceeded', message: 'Cache value byte limit exceeded' });
|
|
125
|
+
}
|
|
126
|
+
const encoded = Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('base64');
|
|
127
|
+
this.write({ text: `["${tag}","${encoded}"]` });
|
|
128
|
+
}
|
|
129
|
+
encodeMap({ value, depth }) {
|
|
130
|
+
this.checkMembers({ additional: value.size * 2 });
|
|
131
|
+
if (Reflect.ownKeys(value).length)
|
|
132
|
+
fail({ code: 'invalid_value', message: 'Cache maps cannot have custom properties' });
|
|
133
|
+
this.write({ text: '["map",[' });
|
|
134
|
+
let separator = '';
|
|
135
|
+
for (const [key, member] of value) {
|
|
136
|
+
this.write({ text: `${separator}[` });
|
|
137
|
+
this.encode({ value: key, depth: depth + 1 });
|
|
138
|
+
this.write({ text: ',' });
|
|
139
|
+
this.encode({ value: member, depth: depth + 1 });
|
|
140
|
+
this.write({ text: ']' });
|
|
141
|
+
separator = ',';
|
|
142
|
+
}
|
|
143
|
+
this.write({ text: ']]' });
|
|
144
|
+
}
|
|
145
|
+
encodeArray({ value, depth }) {
|
|
146
|
+
this.checkMembers({ additional: value.length });
|
|
147
|
+
if (Reflect.ownKeys(value).length !== value.length + 1) {
|
|
148
|
+
fail({ code: 'invalid_value', message: 'Cache arrays must be dense and cannot have custom properties' });
|
|
149
|
+
}
|
|
150
|
+
this.write({ text: '["array",[' });
|
|
151
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
152
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
153
|
+
if (!descriptor || !('value' in descriptor))
|
|
154
|
+
fail({ code: 'invalid_value', message: 'Cache arrays cannot have accessors or holes' });
|
|
155
|
+
if (index)
|
|
156
|
+
this.write({ text: ',' });
|
|
157
|
+
this.encode({ value: descriptor.value, depth: depth + 1 });
|
|
158
|
+
}
|
|
159
|
+
this.write({ text: ']]' });
|
|
160
|
+
}
|
|
161
|
+
encodeRecord({ value, depth, nullPrototype }) {
|
|
162
|
+
const keys = Reflect.ownKeys(value);
|
|
163
|
+
this.checkMembers({ additional: keys.length });
|
|
164
|
+
this.write({ text: nullPrototype ? '["null-record",[' : '["record",[' });
|
|
165
|
+
let separator = '';
|
|
166
|
+
for (const key of keys) {
|
|
167
|
+
if (typeof key !== 'string')
|
|
168
|
+
fail({ code: 'invalid_value', message: 'Cache records cannot have symbol keys' });
|
|
169
|
+
checkKey({ key, code: 'invalid_value' });
|
|
170
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
171
|
+
if (!descriptor || !('value' in descriptor) || !descriptor.enumerable) {
|
|
172
|
+
fail({ code: 'invalid_value', message: 'Cache records must contain enumerable data properties' });
|
|
173
|
+
}
|
|
174
|
+
this.write({ text: `${separator}[` });
|
|
175
|
+
this.writeString({ value: key });
|
|
176
|
+
this.write({ text: ',' });
|
|
177
|
+
this.encode({ value: descriptor.value, depth: depth + 1 });
|
|
178
|
+
this.write({ text: ']' });
|
|
179
|
+
separator = ',';
|
|
180
|
+
}
|
|
181
|
+
this.write({ text: ']]' });
|
|
182
|
+
}
|
|
183
|
+
finish() { return this.parts.join(''); }
|
|
184
|
+
}
|
|
185
|
+
/** Encodes only inert values, without invoking toJSON, getters or object constructors. */
|
|
186
|
+
export function serializeCacheValue({ value, limits }) {
|
|
187
|
+
const writer = new ValueWriter(resolveLimits({ limits }));
|
|
188
|
+
writer.write({ text: '["artifact-next-cache",1,' });
|
|
189
|
+
writer.encode({ value, depth: 0 });
|
|
190
|
+
writer.write({ text: ']' });
|
|
191
|
+
return writer.finish();
|
|
192
|
+
}
|
|
193
|
+
function invalidPayload() {
|
|
194
|
+
return fail({ code: 'invalid_payload', message: 'Invalid serialized cache value' });
|
|
195
|
+
}
|
|
196
|
+
function decodeNumber({ value }) {
|
|
197
|
+
if (typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0))
|
|
198
|
+
return value;
|
|
199
|
+
switch (value) {
|
|
200
|
+
case '-0': return -0;
|
|
201
|
+
case 'NaN': return Number.NaN;
|
|
202
|
+
case 'Infinity': return Infinity;
|
|
203
|
+
case '-Infinity': return -Infinity;
|
|
204
|
+
default: return invalidPayload();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function decodeBytes({ value }) {
|
|
208
|
+
if (typeof value !== 'string' || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
|
|
209
|
+
return invalidPayload();
|
|
210
|
+
}
|
|
211
|
+
const decoded = Buffer.from(value, 'base64');
|
|
212
|
+
if (decoded.toString('base64') !== value)
|
|
213
|
+
return invalidPayload();
|
|
214
|
+
return decoded;
|
|
215
|
+
}
|
|
216
|
+
function decodeEntries({ value, depth, budget, tag }) {
|
|
217
|
+
budget.checkMembers({ additional: value.length * (tag === 'map' ? 2 : 1) });
|
|
218
|
+
if (tag === 'array')
|
|
219
|
+
return value.map((member) => decodeValue({ value: member, depth: depth + 1, budget }));
|
|
220
|
+
const map = new Map();
|
|
221
|
+
const record = tag === 'null-record' ? Object.create(null) : {};
|
|
222
|
+
for (const entry of value) {
|
|
223
|
+
if (!Array.isArray(entry) || entry.length !== 2)
|
|
224
|
+
return invalidPayload();
|
|
225
|
+
if (tag === 'map') {
|
|
226
|
+
const key = decodeValue({ value: entry[0], depth: depth + 1, budget });
|
|
227
|
+
if (map.has(key))
|
|
228
|
+
return invalidPayload();
|
|
229
|
+
map.set(key, decodeValue({ value: entry[1], depth: depth + 1, budget }));
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
const key = entry[0];
|
|
233
|
+
if (typeof key !== 'string' || Object.hasOwn(record, key))
|
|
234
|
+
return invalidPayload();
|
|
235
|
+
checkKey({ key, code: 'invalid_payload' });
|
|
236
|
+
record[key] = decodeValue({ value: entry[1], depth: depth + 1, budget });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return tag === 'map' ? map : record;
|
|
240
|
+
}
|
|
241
|
+
function decodeValue({ value, depth, budget }) {
|
|
242
|
+
budget.visit({ depth });
|
|
243
|
+
if (!Array.isArray(value))
|
|
244
|
+
return invalidPayload();
|
|
245
|
+
const tag = value[0];
|
|
246
|
+
if (tag === 'undefined' || tag === 'null') {
|
|
247
|
+
if (value.length !== 1)
|
|
248
|
+
return invalidPayload();
|
|
249
|
+
return tag === 'null' ? null : undefined;
|
|
250
|
+
}
|
|
251
|
+
if (value.length !== 2)
|
|
252
|
+
return invalidPayload();
|
|
253
|
+
const payload = value[1];
|
|
254
|
+
if (tag === 'string' && typeof payload === 'string')
|
|
255
|
+
return payload;
|
|
256
|
+
if (tag === 'boolean' && typeof payload === 'boolean')
|
|
257
|
+
return payload;
|
|
258
|
+
if (tag === 'number')
|
|
259
|
+
return decodeNumber({ value: payload });
|
|
260
|
+
if (tag === 'buffer' || tag === 'bytes') {
|
|
261
|
+
const bytes = decodeBytes({ value: payload });
|
|
262
|
+
return tag === 'buffer' ? bytes : new Uint8Array(bytes);
|
|
263
|
+
}
|
|
264
|
+
if ((tag === 'array' || tag === 'record' || tag === 'null-record' || tag === 'map') && Array.isArray(payload)) {
|
|
265
|
+
return decodeEntries({ value: payload, depth, budget, tag });
|
|
266
|
+
}
|
|
267
|
+
return invalidPayload();
|
|
268
|
+
}
|
|
269
|
+
export function deserializeCacheValue({ serialized, limits }) {
|
|
270
|
+
const budget = new ValueBudget(resolveLimits({ limits }));
|
|
271
|
+
if (Buffer.byteLength(serialized) > budget.limits.maxBytes)
|
|
272
|
+
fail({ code: 'limit_exceeded', message: 'Cache value byte limit exceeded' });
|
|
273
|
+
let envelope;
|
|
274
|
+
try {
|
|
275
|
+
envelope = JSON.parse(serialized);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return invalidPayload();
|
|
279
|
+
}
|
|
280
|
+
if (!Array.isArray(envelope) || envelope.length !== 3 || envelope[0] !== 'artifact-next-cache' || envelope[1] !== 1) {
|
|
281
|
+
return invalidPayload();
|
|
282
|
+
}
|
|
283
|
+
return decodeValue({ value: envelope[2], depth: 0, budget });
|
|
284
|
+
}
|
|
285
|
+
/** Consumes an owned stream. Callers must pass a cache branch, not a response's live body. */
|
|
286
|
+
export async function readCacheStream({ stream, maxBytes = DEFAULT_CACHE_SERIALIZATION_LIMITS.maxBytes, timeoutMs = 10_000, maxChunks = 65_536, signal, }) {
|
|
287
|
+
for (const [name, value] of Object.entries({ maxBytes, timeoutMs, maxChunks }))
|
|
288
|
+
positiveInteger({ value, name });
|
|
289
|
+
if (timeoutMs > 2_147_483_647)
|
|
290
|
+
fail({ code: 'invalid_value', message: 'Stream timeout exceeds timer range' });
|
|
291
|
+
const reader = stream.getReader();
|
|
292
|
+
let interrupt = () => { };
|
|
293
|
+
const interrupted = new Promise((_resolve, reject) => { interrupt = reject; });
|
|
294
|
+
const timedOut = () => new CacheSerializationError({ code: 'stream_timeout', message: 'Cache stream read timed out' });
|
|
295
|
+
const aborted = () => new CacheSerializationError({ code: 'stream_aborted', message: 'Cache stream read aborted' });
|
|
296
|
+
const onAbort = () => interrupt(aborted());
|
|
297
|
+
const timer = setTimeout(() => interrupt(timedOut()), timeoutMs);
|
|
298
|
+
const deadline = performance.now() + timeoutMs;
|
|
299
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
300
|
+
const chunks = [];
|
|
301
|
+
let bytes = 0;
|
|
302
|
+
try {
|
|
303
|
+
while (true) {
|
|
304
|
+
if (signal?.aborted)
|
|
305
|
+
throw aborted();
|
|
306
|
+
if (performance.now() >= deadline)
|
|
307
|
+
throw timedOut();
|
|
308
|
+
const result = await Promise.race([reader.read(), interrupted]);
|
|
309
|
+
if (result.done)
|
|
310
|
+
return Buffer.concat(chunks, bytes);
|
|
311
|
+
if (!(result.value instanceof Uint8Array))
|
|
312
|
+
fail({ code: 'invalid_value', message: 'Cache stream must contain bytes' });
|
|
313
|
+
bytes += result.value.byteLength;
|
|
314
|
+
if (bytes > maxBytes || chunks.length >= maxChunks)
|
|
315
|
+
fail({ code: 'limit_exceeded', message: 'Cache stream byte or chunk limit exceeded' });
|
|
316
|
+
chunks.push(Buffer.from(result.value));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
// Cancellation hooks belong to the producer and may never settle; do not let them defeat the deadline.
|
|
321
|
+
void reader.cancel(error).catch(() => { });
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
signal?.removeEventListener('abort', onAbort);
|
|
327
|
+
reader.releaseLock();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
export function createCacheStream({ bytes }) {
|
|
331
|
+
const copy = Buffer.from(bytes);
|
|
332
|
+
return new ReadableStream({ start(controller) { controller.enqueue(copy); controller.close(); } });
|
|
333
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** A fresh generation is mandatory for every activation, including rollback. */
|
|
2
|
+
export interface NextCacheNamespace {
|
|
3
|
+
applicationId: string;
|
|
4
|
+
environment: string;
|
|
5
|
+
releaseId: string;
|
|
6
|
+
generation: string;
|
|
7
|
+
}
|
|
8
|
+
export interface CacheWriteTicket {
|
|
9
|
+
incarnation: string;
|
|
10
|
+
epoch: number;
|
|
11
|
+
version: string;
|
|
12
|
+
startedAt: number;
|
|
13
|
+
}
|
|
14
|
+
export interface CacheLookup {
|
|
15
|
+
ticket: CacheWriteTicket;
|
|
16
|
+
entry?: {
|
|
17
|
+
payload: string;
|
|
18
|
+
stale: boolean;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export interface CacheLocation {
|
|
22
|
+
namespace: NextCacheNamespace;
|
|
23
|
+
key: string;
|
|
24
|
+
}
|
|
25
|
+
export interface CacheCommit extends CacheLocation {
|
|
26
|
+
ticket: CacheWriteTicket;
|
|
27
|
+
payload: string;
|
|
28
|
+
tags: string[];
|
|
29
|
+
ttlMs: number;
|
|
30
|
+
}
|
|
31
|
+
/** Each method is atomic. Cache writes must use the ticket from before rendering. */
|
|
32
|
+
export interface NextCacheStore {
|
|
33
|
+
read(input: CacheLocation & {
|
|
34
|
+
tags: string[];
|
|
35
|
+
}): Promise<CacheLookup>;
|
|
36
|
+
commit(input: CacheCommit): Promise<boolean>;
|
|
37
|
+
invalidate(input: {
|
|
38
|
+
namespace: NextCacheNamespace;
|
|
39
|
+
tags: string[];
|
|
40
|
+
expireAfterMs?: number;
|
|
41
|
+
}): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
export declare const MAX_CACHE_TAGS = 128;
|
|
44
|
+
export declare const MAX_CACHE_TAG_BYTES = 256;
|
|
45
|
+
export declare const MAX_CACHE_ENTRY_BYTES: number;
|
|
46
|
+
export declare const MAX_CACHE_ENTRY_AGE_MS: number;
|
|
47
|
+
export declare const MAX_CACHE_WRITE_AGE_MS: number;
|
|
48
|
+
export declare const MAX_NAMESPACE_TAGS = 10000;
|
|
49
|
+
export declare function cacheDigest(value: string): string;
|
|
50
|
+
export declare function cacheNamespacePrefix(namespace: NextCacheNamespace): string;
|
|
51
|
+
export declare function cacheEntryKey({ namespace, key }: CacheLocation): string;
|
|
52
|
+
export declare function normalizeCacheTags(tags: string[]): string[];
|
|
53
|
+
export declare function validateCacheCommit(input: CacheCommit): void;
|
|
54
|
+
export declare function validateExpiration(expireAfterMs?: number): void;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export const MAX_CACHE_TAGS = 128;
|
|
3
|
+
export const MAX_CACHE_TAG_BYTES = 256;
|
|
4
|
+
export const MAX_CACHE_ENTRY_BYTES = 16 * 1024 * 1024;
|
|
5
|
+
export const MAX_CACHE_ENTRY_AGE_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
export const MAX_CACHE_WRITE_AGE_MS = 2 * 60 * 1000;
|
|
7
|
+
export const MAX_NAMESPACE_TAGS = 10_000;
|
|
8
|
+
export function cacheDigest(value) {
|
|
9
|
+
return createHash('sha256').update(value).digest('hex');
|
|
10
|
+
}
|
|
11
|
+
export function cacheNamespacePrefix(namespace) {
|
|
12
|
+
const parts = [namespace.applicationId, namespace.environment, namespace.releaseId, namespace.generation];
|
|
13
|
+
if (parts.some((part) => typeof part !== 'string' || part.length < 1 || part.length > 256)) {
|
|
14
|
+
throw new Error('Next cache requires bounded application, environment, release and generation identifiers');
|
|
15
|
+
}
|
|
16
|
+
// Hashing the tuple prevents delimiter collisions; the hash tag pins Lua keys to one cluster slot.
|
|
17
|
+
return `artifact-next-cache:v1:{${cacheDigest(JSON.stringify(parts))}}`;
|
|
18
|
+
}
|
|
19
|
+
export function cacheEntryKey({ namespace, key }) {
|
|
20
|
+
if (typeof key !== 'string' || key.length < 1 || Buffer.byteLength(key) > 16 * 1024) {
|
|
21
|
+
throw new Error('Invalid Next cache key');
|
|
22
|
+
}
|
|
23
|
+
return `${cacheNamespacePrefix(namespace)}:entry:${cacheDigest(key)}`;
|
|
24
|
+
}
|
|
25
|
+
export function normalizeCacheTags(tags) {
|
|
26
|
+
if (!Array.isArray(tags) || tags.length > MAX_CACHE_TAGS || tags.some((tag) => typeof tag !== 'string' || !tag.length || Buffer.byteLength(tag) > MAX_CACHE_TAG_BYTES)) {
|
|
27
|
+
throw new Error('Invalid Next cache tags');
|
|
28
|
+
}
|
|
29
|
+
return [...new Set(tags)];
|
|
30
|
+
}
|
|
31
|
+
export function validateCacheCommit(input) {
|
|
32
|
+
cacheEntryKey(input);
|
|
33
|
+
normalizeCacheTags(input.tags);
|
|
34
|
+
if (!Number.isSafeInteger(input.ttlMs) || input.ttlMs < 1 || input.ttlMs > MAX_CACHE_ENTRY_AGE_MS) {
|
|
35
|
+
throw new Error('Invalid Next cache retention');
|
|
36
|
+
}
|
|
37
|
+
if (typeof input.payload !== 'string' || Buffer.byteLength(input.payload) > MAX_CACHE_ENTRY_BYTES) {
|
|
38
|
+
throw new Error('Next cache payload exceeds its size limit');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function validateExpiration(expireAfterMs) {
|
|
42
|
+
if (expireAfterMs !== undefined && (!Number.isSafeInteger(expireAfterMs) || expireAfterMs < 0 || expireAfterMs > MAX_CACHE_ENTRY_AGE_MS)) {
|
|
43
|
+
throw new Error('Invalid Next cache invalidation duration');
|
|
44
|
+
}
|
|
45
|
+
}
|