refactor: 리펙토링

This commit is contained in:
DongHyeonka
2026-08-01 19:39:59 +09:00
parent 9c959ea2a5
commit c6da03369c
171 changed files with 20329 additions and 782 deletions
+160
View File
@@ -0,0 +1,160 @@
/**
* §7.9–§7.10. Common bounded body reader.
*
* `bounded-json.ts` stays the V2 reader for already-migrated callers; this
* module adds the byte-level primitives execution V3 needs: a raw bounded read,
* a strict media-type check and the `NONE` one-byte probe.
*/
export type BoundedBytesOutcome =
| Readonly<{ ok: true; bytes: Uint8Array }>
| Readonly<{ ok: false; code: "RESPONSE_TOO_LARGE" | "RESPONSE_STREAM_FAILURE" }>;
export type BodyProbeOutcome =
| Readonly<{ ok: true; present: boolean }>
| Readonly<{ ok: false; code: "RESPONSE_STREAM_FAILURE" }>;
/** Essence match: `application/json` or any `*+json` subtype. */
export function isJsonMediaType(headerValue: string | null): boolean {
if (!headerValue) return false;
const essence = headerValue.split(";", 1)[0]?.trim().toLowerCase() ?? "";
return essence === "application/json" || essence.endsWith("+json");
}
export function declaredContentLength(response: Response): number | null {
const raw = response.headers.get("content-length");
if (raw === null) return null;
const value = Number(raw);
return Number.isFinite(value) && value >= 0 ? value : null;
}
export async function readBoundedBytes(
response: Response,
maximumBytes: number,
): Promise<BoundedBytesOutcome> {
const declared = declaredContentLength(response);
if (declared !== null && declared > maximumBytes) {
await cancelBody(response);
return failure("RESPONSE_TOO_LARGE");
}
if (!response.body) {
return Object.freeze({ ok: true as const, bytes: new Uint8Array(0) });
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const next = await reader.read();
if (next.done) break;
if (!next.value) continue;
total += next.value.byteLength;
if (total > maximumBytes) {
await reader.cancel().catch(() => {});
return failure("RESPONSE_TOO_LARGE");
}
chunks.push(next.value);
}
} catch {
await reader.cancel().catch(() => {});
return failure("RESPONSE_STREAM_FAILURE");
} finally {
try {
reader.releaseLock();
} catch {
// A cancelled reader has already released its lock.
}
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return Object.freeze({ ok: true as const, bytes });
}
/**
* §7.10 `NONE`. A declared positive length is an immediate violation. Otherwise
* at most one byte is probed: the descriptor does not permit an unexpected
* body, so the runtime never drains an arbitrary amount to find out.
*/
export async function probeForbiddenBody(
response: Response,
): Promise<BodyProbeOutcome> {
const declared = declaredContentLength(response);
if (declared !== null && declared > 0) {
await cancelBody(response);
return Object.freeze({ ok: true as const, present: true });
}
if (!response.body) {
return Object.freeze({ ok: true as const, present: false });
}
const reader = response.body.getReader();
try {
const next = await reader.read();
if (next.done || !next.value || next.value.byteLength === 0) {
return Object.freeze({ ok: true as const, present: false });
}
await reader.cancel().catch(() => {});
return Object.freeze({ ok: true as const, present: true });
} catch {
await reader.cancel().catch(() => {});
return Object.freeze({
ok: false as const,
code: "RESPONSE_STREAM_FAILURE" as const,
});
} finally {
try {
reader.releaseLock();
} catch {
// Already released by cancel().
}
}
}
export type DecodedJson =
| Readonly<{ ok: true; value: unknown }>
| Readonly<{ ok: false; code: "UTF8_INVALID" | "JSON_INVALID" }>;
export function decodeJsonBytes(bytes: Uint8Array): DecodedJson {
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return Object.freeze({ ok: false as const, code: "UTF8_INVALID" as const });
}
try {
return Object.freeze({ ok: true as const, value: JSON.parse(text) });
} catch {
return Object.freeze({ ok: false as const, code: "JSON_INVALID" as const });
}
}
export function isEffectivelyEmpty(bytes: Uint8Array): boolean {
if (bytes.byteLength === 0) return true;
for (const byte of bytes) {
// Space, tab, LF, CR are the only permitted "empty" filler.
if (byte !== 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) {
return false;
}
}
return true;
}
async function cancelBody(response: Response): Promise<void> {
try {
await response.body?.cancel();
} catch {
// Cancelling an already-settled body is not itself a failure.
}
}
function failure(
code: "RESPONSE_TOO_LARGE" | "RESPONSE_STREAM_FAILURE",
): BoundedBytesOutcome {
return Object.freeze({ ok: false as const, code });
}
+288
View File
@@ -0,0 +1,288 @@
import {
HTTP_EXECUTION_CEILINGS,
type InstalledHttpContract,
} from "../../contracts/external-contract-runtime.ts";
/**
* §7.4–§7.7. Descriptor-driven request projection.
*
* Nothing here re-derives operation semantics. The package descriptor supplies
* path values, query entry order and the body value; this module only encodes,
* bounds and re-verifies them.
*/
export type CredentialPatchOutcome =
| Readonly<{
kind: "READY";
headers: Readonly<Record<string, string>>;
credentials: RequestCredentials;
}>
| Readonly<{ kind: "UNAUTHENTICATED" }>
| Readonly<{ kind: "UNAVAILABLE" }>
| Readonly<{ kind: "SCOPE_FENCED" }>;
/** §7.7. The complete set of headers a credential bridge may contribute. */
export const ALLOWED_CREDENTIAL_HEADERS: ReadonlySet<string> = new Set([
"authorization",
"x-csrf-token",
"x-tenant-context",
]);
const FORBIDDEN_REQUEST_HEADERS: ReadonlySet<string> = new Set([
"host",
"origin",
"referer",
"cookie",
"content-length",
"connection",
"transfer-encoding",
"upgrade",
]);
export type RequestProjectionFailure =
| "PROJECTION_RUNTIME_FAILURE"
| "PROJECTION_INVALID"
| "PATH_PLACEHOLDER_MISSING"
| "PATH_VALUE_INVALID"
| "QUERY_TOO_LARGE"
| "URL_TOO_LARGE"
| "URL_ORIGIN_ESCAPED"
| "REQUEST_BODY_TOO_LARGE"
| "REQUEST_BODY_UNENCODABLE"
| "REQUEST_BODY_UNEXPECTED";
export type ProjectedRequest = Readonly<{
url: string;
method: string;
bodyBytes: Uint8Array | null;
}>;
export type RequestProjectionOutcome =
| Readonly<{ ok: true; request: ProjectedRequest }>
| Readonly<{ ok: false; failure: RequestProjectionFailure }>;
const PLACEHOLDER = /\{([A-Za-z][A-Za-z0-9_]*)\}|:([A-Za-z][A-Za-z0-9_]*)/g;
const encoder = new TextEncoder();
/**
* §7.5. Built from the validated base URL and the descriptor's named values.
* Segments are encoded exactly once; a raw slash inside a value is rejected
* rather than silently creating a new path segment.
*/
export function projectRequest<Input, WireOutput, Problem>(
installed: InstalledHttpContract<Input, WireOutput, Problem>,
input: Input,
baseUrl: string,
): RequestProjectionOutcome {
try {
return projectRequestChecked(installed, input, baseUrl);
} catch {
return failure("PROJECTION_RUNTIME_FAILURE");
}
}
function projectRequestChecked<Input, WireOutput, Problem>(
installed: InstalledHttpContract<Input, WireOutput, Problem>,
input: Input,
baseUrl: string,
): RequestProjectionOutcome {
const contract = installed.contract;
const projection = contract.projectRequest(input);
if (!isValidProjection(projection)) return failure("PROJECTION_INVALID");
let missing = false;
let invalid = false;
const path = contract.pathTemplate.replace(
PLACEHOLDER,
(_match, braced?: string, colon?: string) => {
const name = braced ?? colon ?? "";
const value = projection.pathValues[name];
if (typeof value !== "string" || value.length === 0) {
missing = true;
return "";
}
if (value.includes("/") || value.includes("\\")) {
invalid = true;
return "";
}
return encodeURIComponent(value);
},
);
if (missing) return failure("PATH_PLACEHOLDER_MISSING");
if (invalid) return failure("PATH_VALUE_INVALID");
const base = new URL(baseUrl);
const url = new URL(path.replace(/^\/+/, ""), base);
if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) {
return failure("URL_ORIGIN_ESCAPED");
}
const search = new URLSearchParams();
for (const [key, value] of projection.queryEntries) {
search.append(key, value);
}
const encodedQuery = search.toString();
if (
encoder.encode(encodedQuery).byteLength >
HTTP_EXECUTION_CEILINGS.encodedQueryBytes
) {
return failure("QUERY_TOO_LARGE");
}
url.search = encodedQuery;
if (encoder.encode(url.href).byteLength > HTTP_EXECUTION_CEILINGS.finalUrlBytes) {
return failure("URL_TOO_LARGE");
}
let bodyBytes: Uint8Array | null = null;
if (contract.requestBody === "JSON") {
let encoded: string;
try {
encoded = JSON.stringify(projection.body);
} catch {
return failure("REQUEST_BODY_UNENCODABLE");
}
if (typeof encoded !== "string") {
return failure("REQUEST_BODY_UNENCODABLE");
}
bodyBytes = encoder.encode(encoded);
if (bodyBytes.byteLength > installed.frontend.requestByteLimit) {
return failure("REQUEST_BODY_TOO_LARGE");
}
} else if (projection.body !== null && projection.body !== undefined) {
return failure("REQUEST_BODY_UNEXPECTED");
}
return Object.freeze({
ok: true as const,
request: Object.freeze({
url: url.href,
method: contract.method,
bodyBytes,
}),
});
}
function isValidProjection(
value: unknown,
): value is ReturnType<
InstalledHttpContract<unknown, unknown, unknown>["contract"]["projectRequest"]
> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const candidate = value as Record<string, unknown>;
const keys = Object.keys(candidate).sort();
if (keys.join("|") !== "body|pathValues|queryEntries") return false;
const pathValues = candidate.pathValues;
if (!pathValues || typeof pathValues !== "object" || Array.isArray(pathValues)) {
return false;
}
const pathPrototype = Object.getPrototypeOf(pathValues);
if (pathPrototype !== Object.prototype && pathPrototype !== null) return false;
const pathEntries = Object.entries(pathValues as Record<string, unknown>);
if (pathEntries.length > 32) return false;
for (const [key, pathValue] of pathEntries) {
if (
!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key) ||
typeof pathValue !== "string" ||
pathValue.length === 0 ||
encoder.encode(pathValue).byteLength > HTTP_EXECUTION_CEILINGS.pathTemplateBytes
) {
return false;
}
}
const queryEntries = candidate.queryEntries;
if (!Array.isArray(queryEntries) || queryEntries.length > 256) return false;
for (const entry of queryEntries) {
if (
!Array.isArray(entry) ||
entry.length !== 2 ||
typeof entry[0] !== "string" ||
typeof entry[1] !== "string"
) {
return false;
}
}
return true;
}
export type FinalInvariantInput = Readonly<{
request: ProjectedRequest;
expectedMethod: string;
baseUrl: string;
init: RequestInit;
headers: Readonly<Record<string, string>>;
requestByteLimit: number;
deadlineRemainingMs: number;
scopeIsCurrent: boolean;
}>;
export type FinalInvariantFailure =
| "METHOD_CHANGED"
| "URL_NOT_ALLOWED"
| "REDIRECT_MODE_INVALID"
| "CREDENTIALS_MODE_INVALID"
| "HEADER_NOT_ALLOWED"
| "FORBIDDEN_HEADER"
| "REQUEST_BODY_TOO_LARGE"
| "DEADLINE_EXPIRED"
| "SCOPE_FENCED";
/**
* §7.4. Runs after the credential patch and immediately before dispatch. A
* failure here means `fetch()` is called zero times, so a defective auth
* adapter can never alter the method, target or transport policy.
*/
export function checkFinalInvariants(
input: FinalInvariantInput,
): FinalInvariantFailure | null {
if (input.request.method !== input.expectedMethod) return "METHOD_CHANGED";
const base = new URL(input.baseUrl);
let target: URL;
try {
target = new URL(input.request.url);
} catch {
return "URL_NOT_ALLOWED";
}
if (target.origin !== base.origin || !target.pathname.startsWith(base.pathname)) {
return "URL_NOT_ALLOWED";
}
if (input.init.redirect !== "error") return "REDIRECT_MODE_INVALID";
if (
input.init.credentials !== "omit" &&
input.init.credentials !== "same-origin" &&
input.init.credentials !== "include"
) {
return "CREDENTIALS_MODE_INVALID";
}
for (const name of Object.keys(input.headers)) {
const lower = name.toLowerCase();
if (FORBIDDEN_REQUEST_HEADERS.has(lower)) return "FORBIDDEN_HEADER";
if (
lower !== "accept" &&
lower !== "content-type" &&
lower !== "idempotency-key" &&
!ALLOWED_CREDENTIAL_HEADERS.has(lower)
) {
return "HEADER_NOT_ALLOWED";
}
}
if (
input.request.bodyBytes &&
input.request.bodyBytes.byteLength > input.requestByteLimit
) {
return "REQUEST_BODY_TOO_LARGE";
}
if (input.deadlineRemainingMs <= 0) return "DEADLINE_EXPIRED";
if (!input.scopeIsCurrent) return "SCOPE_FENCED";
return null;
}
function failure(failureKind: RequestProjectionFailure): RequestProjectionOutcome {
return Object.freeze({ ok: false as const, failure: failureKind });
}
+151
View File
@@ -0,0 +1,151 @@
import type {
CommandEffectDescriptor,
CommandEffectClassification,
} from "../../contracts/external-contract-runtime.ts";
/**
* §8.7–§8.9. Mutation effect certainty.
*
* The frontend never infers "not applied" from an HTTP status alone. Anything
* observed after the request was dispatched but before a classified terminal
* response is `MAYBE_APPLIED`, which forbids automatic resend.
*/
export type MutationEffectCertainty =
| "NOT_STARTED"
| "NOT_APPLIED"
| "MAYBE_APPLIED"
| "APPLIED_CONFIRMED";
export type PhysicalAttemptState =
| "NOT_STARTED"
| "PREPARING"
| "READY_TO_SEND"
| "DISPATCHED"
| "RESPONSE_HEADERS"
| "READING_BODY"
| "VALIDATING"
| "MAPPING_READY"
| "SETTLED";
/**
* `READY_TO_SEND` is recorded immediately before entering the `fetch()`
* invocation expression and `DISPATCHED` immediately after the promise is
* returned. A synchronous throw therefore leaves the attempt `NOT_STARTED`.
*/
export function certaintyForAbandonedAttempt(
state: PhysicalAttemptState,
isCommand: boolean,
): MutationEffectCertainty {
if (!isCommand) return "NOT_STARTED";
switch (state) {
case "NOT_STARTED":
case "PREPARING":
case "READY_TO_SEND":
return "NOT_STARTED";
default:
return "MAYBE_APPLIED";
}
}
export type ProblemEffectInput<Problem> = Readonly<{
status: number;
problem: Problem;
descriptor: CommandEffectDescriptor<Problem> | null;
}>;
export type ProblemEffectOutcome = Readonly<{
effect: MutationEffectCertainty;
contractRuntimeFailure: boolean;
}>;
const CLASSIFICATIONS: ReadonlySet<CommandEffectClassification> = new Set([
"NOT_APPLIED",
"APPLIED_CONFIRMED",
"MAYBE_APPLIED",
]);
/**
* §4.4. The classifier is package-owned and pure. A throw or an unrecognised
* return value fails safe to `MAYBE_APPLIED` and is recorded as a contract
* runtime failure rather than being silently treated as "not applied".
*/
export function classifyProblemEffect<Problem>(
input: ProblemEffectInput<Problem>,
): ProblemEffectOutcome {
if (!input.descriptor) {
// A read operation carries no command effect; there is nothing to apply.
return Object.freeze({
effect: "NOT_STARTED" as const,
contractRuntimeFailure: false,
});
}
let classification: CommandEffectClassification;
try {
classification = input.descriptor.classifyProblem({
status: input.status,
problem: input.problem,
});
} catch {
return Object.freeze({
effect: "MAYBE_APPLIED" as const,
contractRuntimeFailure: true,
});
}
if (!CLASSIFICATIONS.has(classification)) {
return Object.freeze({
effect: "MAYBE_APPLIED" as const,
contractRuntimeFailure: true,
});
}
return Object.freeze({
effect: classification,
contractRuntimeFailure: false,
});
}
export type MutationIntent = Readonly<{
intentId: string;
operationId: string;
canonicalInputIdentity: string;
idempotencyKey?: string;
createdAtMonotonicMs: number;
}>;
export type MutationIntentContext = Readonly<{
intentId: string;
idempotencyKey?: string;
startedBy: "USER" | "FOREGROUND_RETRY" | "OUTBOX_REPLAY";
}>;
export function createMutationIntent(
input: Readonly<{
operationId: string;
canonicalInputIdentity: string;
idempotencyKey?: string;
monotonicNow?: () => number;
}>,
): MutationIntent {
const now = input.monotonicNow ?? (() => performance.now());
return Object.freeze({
intentId: crypto.randomUUID(),
operationId: input.operationId,
canonicalInputIdentity: input.canonicalInputIdentity,
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
createdAtMonotonicMs: now(),
});
}
/** §8.10. Certainty to UI intent. The copy itself is owned by the i18n catalog. */
export function projectCertaintyToUi(
certainty: MutationEffectCertainty,
): "RETRYABLE" | "CHECK_STATUS" | "SUCCESS" {
switch (certainty) {
case "APPLIED_CONFIRMED":
return "SUCCESS";
case "MAYBE_APPLIED":
return "CHECK_STATUS";
default:
return "RETRYABLE";
}
}
File diff suppressed because it is too large Load Diff