feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
composeBrowserRpcOperationRegistry,
|
||||
composeBrowserRpcProviderProfileRegistry,
|
||||
composeBrowserRpcRequestEncoderRegistry,
|
||||
defineBrowserRpcOperation,
|
||||
defineBrowserRpcProviderProfile,
|
||||
validateBrowserRpcContractBindings,
|
||||
type BrowserRpcProviderProfile,
|
||||
} from "../../../src/contracts/browser-rpc.ts";
|
||||
import {
|
||||
DESCRIPTOR_DIGEST,
|
||||
MAPPERS,
|
||||
RUNTIME_DIGEST,
|
||||
SCHEMA_CODECS,
|
||||
STREAM_ENCODER,
|
||||
UNARY_ENCODER,
|
||||
streamOperation,
|
||||
streamProfile,
|
||||
unaryOperation,
|
||||
unaryProfile,
|
||||
} from "./fixture.ts";
|
||||
|
||||
describe("Browser RPC contract registry", () => {
|
||||
it("closes exact operation, provider, schema, mapper and encoder bindings", () => {
|
||||
const operation = unaryOperation();
|
||||
const profile = unaryProfile();
|
||||
const operations = composeBrowserRpcOperationRegistry([
|
||||
{ GET_RPC_RESOURCE: operation },
|
||||
]);
|
||||
const profiles = composeBrowserRpcProviderProfileRegistry([
|
||||
{ CONNECT_REFERENCE_UNARY: profile },
|
||||
]);
|
||||
const encoders = composeBrowserRpcRequestEncoderRegistry([
|
||||
{ RpcResourceRequestEncoder: UNARY_ENCODER },
|
||||
]);
|
||||
|
||||
expect(
|
||||
validateBrowserRpcContractBindings({
|
||||
operations,
|
||||
profiles,
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: encoders,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(Object.isFrozen(operations)).toBe(true);
|
||||
expect(Object.isFrozen(profiles.CONNECT_REFERENCE_UNARY)).toBe(true);
|
||||
expect(
|
||||
Object.isFrozen(
|
||||
profiles.CONNECT_REFERENCE_UNARY?.allowedProcedures,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate rows and descriptor/provider drift", () => {
|
||||
const operation = unaryOperation();
|
||||
expect(() =>
|
||||
composeBrowserRpcOperationRegistry([
|
||||
{ GET_RPC_RESOURCE: operation },
|
||||
{ GET_RPC_RESOURCE: operation },
|
||||
]),
|
||||
).toThrow("duplicate Browser RPC operation");
|
||||
|
||||
expect(() =>
|
||||
validateBrowserRpcContractBindings({
|
||||
operations: { GET_RPC_RESOURCE: operation },
|
||||
profiles: {
|
||||
CONNECT_REFERENCE_UNARY: unaryProfile({
|
||||
descriptorDigest: "c".repeat(64),
|
||||
}),
|
||||
},
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceRequestEncoder: UNARY_ENCODER,
|
||||
},
|
||||
}),
|
||||
).toThrow("provider binding is invalid");
|
||||
});
|
||||
|
||||
it("permits only a public headerless NO_SIDE_EFFECTS Connect GET", () => {
|
||||
const getProfile = defineBrowserRpcProviderProfile({
|
||||
...unaryProfile(),
|
||||
runtimeProfileId: "CONNECT_PUBLIC_GET",
|
||||
requestMethod: "GET",
|
||||
authProfileId: "ANONYMOUS",
|
||||
csrfProfileId: "NONE",
|
||||
allowedProcedures: [
|
||||
"example.resource.v1.ResourceService/GetResource",
|
||||
],
|
||||
});
|
||||
const validGet = defineBrowserRpcOperation({
|
||||
...unaryOperation(),
|
||||
runtimeProfileId: "CONNECT_PUBLIC_GET",
|
||||
authProfileId: "ANONYMOUS",
|
||||
csrfProfileId: "NONE",
|
||||
idempotencyLevel: "NO_SIDE_EFFECTS",
|
||||
dataClassification: "PUBLIC",
|
||||
});
|
||||
expect(
|
||||
validateBrowserRpcContractBindings({
|
||||
operations: { GET_RPC_RESOURCE: validGet },
|
||||
profiles: { CONNECT_PUBLIC_GET: getProfile },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceRequestEncoder: UNARY_ENCODER,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(() =>
|
||||
validateBrowserRpcContractBindings({
|
||||
operations: {
|
||||
GET_RPC_RESOURCE: defineBrowserRpcOperation({
|
||||
...validGet,
|
||||
dataClassification: "CONFIDENTIAL",
|
||||
}),
|
||||
},
|
||||
profiles: { CONNECT_PUBLIC_GET: getProfile },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceRequestEncoder: UNARY_ENCODER,
|
||||
},
|
||||
}),
|
||||
).toThrow("GET binding is invalid");
|
||||
});
|
||||
|
||||
it("separates official grpc-web, Connect-Web and Connect tuples", () => {
|
||||
expect(() =>
|
||||
defineBrowserRpcProviderProfile({
|
||||
...streamProfile(),
|
||||
runtimeProfileId: "OFFICIAL_BINARY_STREAM",
|
||||
runtimeId: "official-grpc-web",
|
||||
runtimeVersion: "1.5.0",
|
||||
runtimeDigest: RUNTIME_DIGEST,
|
||||
protocol: "GRPC_WEB",
|
||||
runtimeKind: "OFFICIAL_GRPC_WEB_XHR",
|
||||
clientApiKind: "CALLBACK_STREAM",
|
||||
messageEncoding: "PROTO",
|
||||
framing: "GRPC_WEB_BINARY_ENVELOPE",
|
||||
deadlineDialect: "OFFICIAL_DEADLINE_METADATA",
|
||||
cancelDialect: "CLIENT_READABLE_STREAM_CANCEL",
|
||||
descriptorDigest: DESCRIPTOR_DIGEST,
|
||||
}),
|
||||
).toThrow("provider profile is invalid");
|
||||
|
||||
expect(() =>
|
||||
defineBrowserRpcProviderProfile({
|
||||
...unaryProfile(),
|
||||
framing: "GRPC_WEB_BINARY_ENVELOPE",
|
||||
}),
|
||||
).toThrow("provider profile is invalid");
|
||||
|
||||
expect(
|
||||
defineBrowserRpcProviderProfile({
|
||||
...unaryProfile(),
|
||||
runtimeProfileId: "CONNECT_GRPC_WEB_UNARY",
|
||||
protocol: "GRPC_WEB",
|
||||
framing: "GRPC_WEB_BINARY_ENVELOPE",
|
||||
deadlineDialect: "GRPC_TIMEOUT",
|
||||
}),
|
||||
).toMatchObject({
|
||||
protocol: "GRPC_WEB",
|
||||
runtimeKind: "CONNECT_WEB_FETCH",
|
||||
deadlineDialect: "GRPC_TIMEOUT",
|
||||
});
|
||||
});
|
||||
|
||||
it("revalidates raw registry rows instead of trusting TypeScript assertions", () => {
|
||||
const invalidProfile = {
|
||||
...unaryProfile(),
|
||||
messageEncoding: "XML",
|
||||
} as unknown as BrowserRpcProviderProfile;
|
||||
|
||||
expect(() =>
|
||||
validateBrowserRpcContractBindings({
|
||||
operations: { GET_RPC_RESOURCE: unaryOperation() },
|
||||
profiles: { CONNECT_REFERENCE_UNARY: invalidProfile },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceRequestEncoder: UNARY_ENCODER,
|
||||
},
|
||||
}),
|
||||
).toThrow("provider profile is invalid");
|
||||
|
||||
expect(() =>
|
||||
validateBrowserRpcContractBindings({
|
||||
operations: { WRONG_REGISTRY_KEY: unaryOperation() },
|
||||
profiles: { CONNECT_REFERENCE_UNARY: unaryProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceRequestEncoder: UNARY_ENCODER,
|
||||
},
|
||||
}),
|
||||
).toThrow("operation registry is invalid");
|
||||
});
|
||||
|
||||
it("disallows frontend stream retry and unsafe unary replay", () => {
|
||||
expect(() =>
|
||||
defineBrowserRpcProviderProfile({
|
||||
...streamProfile(),
|
||||
retryOwner: "FRONTEND_ADAPTER",
|
||||
maxAttempts: 2,
|
||||
backoffMs: [10],
|
||||
retryableFailures: ["UNAVAILABLE"],
|
||||
}),
|
||||
).toThrow("provider profile is invalid");
|
||||
|
||||
const retryProfile = unaryProfile({
|
||||
retryProfileId: "RPC_RETRY_TWO",
|
||||
retryOwner: "FRONTEND_ADAPTER",
|
||||
maxAttempts: 2,
|
||||
backoffMs: [10],
|
||||
retryableFailures: ["UNAVAILABLE"],
|
||||
});
|
||||
expect(() =>
|
||||
validateBrowserRpcContractBindings({
|
||||
operations: {
|
||||
CREATE_RPC_RESOURCE: defineBrowserRpcOperation({
|
||||
...unaryOperation(),
|
||||
operationId: "CREATE_RPC_RESOURCE",
|
||||
semantics: "COMMAND",
|
||||
replayPolicy: "NON_REPLAYABLE",
|
||||
runtimeProfileId: retryProfile.runtimeProfileId,
|
||||
retryProfileId: retryProfile.retryProfileId,
|
||||
}),
|
||||
},
|
||||
profiles: { CONNECT_REFERENCE_UNARY: retryProfile },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceRequestEncoder: {
|
||||
...UNARY_ENCODER,
|
||||
operationId: "CREATE_RPC_RESOURCE",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow("retry binding is invalid");
|
||||
});
|
||||
|
||||
it("supports a bounded Connect server-stream contract without composing it", () => {
|
||||
expect(
|
||||
validateBrowserRpcContractBindings({
|
||||
operations: {
|
||||
WATCH_RPC_RESOURCES: streamOperation(),
|
||||
},
|
||||
profiles: {
|
||||
CONNECT_REFERENCE_STREAM: streamProfile(),
|
||||
},
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceStreamRequestEncoder: STREAM_ENCODER,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createBrowserRpcRuntime,
|
||||
createUnavailableBrowserRpcTransport,
|
||||
defineBrowserRpcTransport,
|
||||
type BrowserRpcObservation,
|
||||
type BrowserRpcStreamFrame,
|
||||
type BrowserRpcTransport,
|
||||
} from "../../../src/adapters/browser-rpc/index.ts";
|
||||
import type { Result } from "../../../src/application/result.ts";
|
||||
import type { AppFailure } from "../../../src/contracts/errors.ts";
|
||||
import {
|
||||
MAPPERS,
|
||||
SCHEMA_CODECS,
|
||||
STREAM_ENCODER,
|
||||
UNARY_ENCODER,
|
||||
isResourceView,
|
||||
streamOperation,
|
||||
streamProfile,
|
||||
unaryOperation,
|
||||
unaryProfile,
|
||||
type ResourceView,
|
||||
} from "./fixture.ts";
|
||||
|
||||
describe("Browser RPC provider-neutral runtime", () => {
|
||||
it("validates, encodes, maps and admits only the typed unary result", async () => {
|
||||
const observations: BrowserRpcObservation[] = [];
|
||||
const transport = defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "UNARY",
|
||||
async invokeUnary(call) {
|
||||
expect(call.request).toEqual({ resourceId: "resource-1" });
|
||||
expect(call.timeoutMs).toBeGreaterThan(0);
|
||||
return Object.freeze({
|
||||
ok: true,
|
||||
message: Object.freeze({ id: "resource-1", name: "Resource one" }),
|
||||
encodedBytes: 48,
|
||||
});
|
||||
},
|
||||
});
|
||||
const port = unaryRuntime(transport, {
|
||||
observe(value) {
|
||||
observations.push(value);
|
||||
},
|
||||
}).bindUnary("GET_RPC_RESOURCE", isResourceView);
|
||||
|
||||
await expect(port.execute({ resourceId: "resource-1" })).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { id: "resource-1", label: "Resource one" },
|
||||
});
|
||||
expect(observations).toEqual([
|
||||
{
|
||||
operationId: "GET_RPC_RESOURCE",
|
||||
protocol: "CONNECT_HTTP",
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
rpcKind: "UNARY",
|
||||
outcome: "SUCCESS",
|
||||
attemptCount: 1,
|
||||
messageCount: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails before transport on invalid input or unexpected idempotency metadata", async () => {
|
||||
let calls = 0;
|
||||
const transport = defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "UNARY",
|
||||
async invokeUnary() {
|
||||
calls += 1;
|
||||
return {
|
||||
ok: false,
|
||||
failure: { code: "UNAVAILABLE" },
|
||||
};
|
||||
},
|
||||
});
|
||||
const port = unaryRuntime(transport).bindUnary(
|
||||
"GET_RPC_RESOURCE",
|
||||
isResourceView,
|
||||
);
|
||||
|
||||
const invalid = await port.execute({ resourceId: 42 });
|
||||
expect(invalid).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "VALIDATION_REJECTED",
|
||||
code: "RPC_REQUEST_SCHEMA_INVALID",
|
||||
},
|
||||
});
|
||||
const metadata = await port.execute(
|
||||
{ resourceId: "resource-1" },
|
||||
{ idempotencyKey: "caller-key-is-not-allowed" },
|
||||
);
|
||||
expect(metadata).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "VALIDATION_REJECTED",
|
||||
code: "RPC_IDEMPOTENCY_KEY_INVALID",
|
||||
},
|
||||
});
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the frontend retry owner bounded by replay policy and one total deadline", async () => {
|
||||
let calls = 0;
|
||||
const profile = unaryProfile({
|
||||
retryProfileId: "RPC_RETRY_TWO",
|
||||
retryOwner: "FRONTEND_ADAPTER",
|
||||
maxAttempts: 2,
|
||||
backoffMs: [0],
|
||||
retryableFailures: ["UNAVAILABLE"],
|
||||
maxRetryAfterMs: 100,
|
||||
});
|
||||
const operation = unaryOperation({
|
||||
retryProfileId: "RPC_RETRY_TWO",
|
||||
});
|
||||
const transport = defineBrowserRpcTransport({
|
||||
runtimeProfileId: profile.runtimeProfileId,
|
||||
providerId: profile.providerId,
|
||||
protocol: profile.protocol,
|
||||
rpcKind: profile.rpcKind,
|
||||
async invokeUnary() {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: { code: "UNAVAILABLE" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
message: { id: "resource-2", name: "Retried resource" },
|
||||
encodedBytes: 32,
|
||||
};
|
||||
},
|
||||
});
|
||||
const runtime = createBrowserRpcRuntime({
|
||||
operations: { GET_RPC_RESOURCE: operation },
|
||||
profiles: { CONNECT_REFERENCE_UNARY: profile },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceRequestEncoder: UNARY_ENCODER,
|
||||
},
|
||||
transports: { CONNECT_REFERENCE_UNARY: transport },
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime
|
||||
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||
.execute({ resourceId: "resource-2" }),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { id: "resource-2", label: "Retried resource" },
|
||||
});
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("drops a late unary result after its scope generation changes", async () => {
|
||||
const transport = defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "UNARY",
|
||||
async invokeUnary() {
|
||||
return {
|
||||
ok: true,
|
||||
message: { id: "late", name: "Late resource" },
|
||||
encodedBytes: 24,
|
||||
};
|
||||
},
|
||||
});
|
||||
const runtime = createBrowserRpcRuntime({
|
||||
...baseDependencies(transport),
|
||||
generationFence: {
|
||||
capture: () => 1,
|
||||
isCurrent: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime
|
||||
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||
.execute({ resourceId: "late" }),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "SCOPE_GENERATION_CHANGED",
|
||||
code: "RPC_SCOPE_GENERATION_CHANGED",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an explicit unavailable adapter without network fallback", async () => {
|
||||
const transport = createUnavailableBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "UNARY",
|
||||
});
|
||||
const result = await unaryRuntime(transport)
|
||||
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||
.execute({ resourceId: "resource-1" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "SERVER_FAILURE",
|
||||
code: "RPC_UNAVAILABLE",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies valid oversized transport metadata as a response limit", async () => {
|
||||
const transport = defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "UNARY",
|
||||
async invokeUnary() {
|
||||
return {
|
||||
ok: true,
|
||||
message: { id: "large", name: "Large resource" },
|
||||
encodedBytes: 4_097,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
unaryRuntime(transport)
|
||||
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||
.execute({ resourceId: "large" }),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "RESPONSE_BODY_LIMIT",
|
||||
code: "RPC_RESPONSE_MESSAGE_LIMIT",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects transports that expose the wrong call shape", () => {
|
||||
expect(() =>
|
||||
defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "UNARY",
|
||||
async invokeUnary() {
|
||||
return {
|
||||
ok: false,
|
||||
failure: { code: "UNAVAILABLE" },
|
||||
};
|
||||
},
|
||||
async *openServerStream() {
|
||||
yield Object.freeze({ kind: "TERMINAL", ok: true });
|
||||
},
|
||||
}),
|
||||
).toThrow("transport is invalid");
|
||||
});
|
||||
|
||||
it("commits mapped stream messages only before one valid terminal envelope", async () => {
|
||||
const transport = streamTransport(async function* () {
|
||||
yield message("resource-1", "One", 24);
|
||||
yield message("resource-2", "Two", 24);
|
||||
yield Object.freeze({ kind: "TERMINAL", ok: true });
|
||||
});
|
||||
const observations: BrowserRpcObservation[] = [];
|
||||
const stream = streamRuntime(transport, {
|
||||
observe(value) {
|
||||
observations.push(value);
|
||||
},
|
||||
})
|
||||
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
||||
.open({ resourceId: "scope-1" });
|
||||
|
||||
await expect(collect(stream)).resolves.toEqual([
|
||||
{
|
||||
ok: true,
|
||||
value: { id: "resource-1", label: "One" },
|
||||
},
|
||||
{
|
||||
ok: true,
|
||||
value: { id: "resource-2", label: "Two" },
|
||||
},
|
||||
]);
|
||||
expect(observations).toEqual([
|
||||
{
|
||||
operationId: "WATCH_RPC_RESOURCES",
|
||||
protocol: "CONNECT_HTTP",
|
||||
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
|
||||
rpcKind: "SERVER_STREAM",
|
||||
outcome: "SUCCESS",
|
||||
attemptCount: 1,
|
||||
messageCount: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects EOF without terminal and data after terminal", async () => {
|
||||
const missingTerminal = streamTransport(async function* () {
|
||||
yield message("resource-1", "One", 24);
|
||||
});
|
||||
const missingResults = await collect(
|
||||
streamRuntime(missingTerminal)
|
||||
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
||||
.open({ resourceId: "scope-1" }),
|
||||
);
|
||||
expect(missingResults.at(-1)).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "API_CONTRACT_MISMATCH",
|
||||
code: "RPC_PROTOCOL_MISMATCH",
|
||||
},
|
||||
});
|
||||
|
||||
const afterTerminal = streamTransport(async function* () {
|
||||
yield Object.freeze({ kind: "TERMINAL", ok: true });
|
||||
yield message("resource-2", "Two", 24);
|
||||
});
|
||||
await expect(
|
||||
collect(
|
||||
streamRuntime(afterTerminal)
|
||||
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
||||
.open({ resourceId: "scope-1" }),
|
||||
),
|
||||
).resolves.toMatchObject([
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "API_CONTRACT_MISMATCH",
|
||||
code: "RPC_PROTOCOL_MISMATCH",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("aborts the transport when stream message limits are exceeded", async () => {
|
||||
let cleaned = false;
|
||||
const transport = streamTransport(async function* (signal) {
|
||||
try {
|
||||
yield message("resource-1", "One", 24);
|
||||
yield message("resource-2", "Two", 24);
|
||||
yield Object.freeze({ kind: "TERMINAL", ok: true });
|
||||
} finally {
|
||||
cleaned = signal.aborted;
|
||||
}
|
||||
});
|
||||
const operation = streamOperation({ maxResponseMessages: 1 });
|
||||
const results = await collect(
|
||||
streamRuntime(transport, undefined, operation)
|
||||
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
||||
.open({ resourceId: "scope-1" }),
|
||||
);
|
||||
|
||||
expect(results.at(-1)).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "RESPONSE_BODY_LIMIT",
|
||||
code: "RPC_STREAM_MESSAGE_LIMIT",
|
||||
},
|
||||
});
|
||||
expect(cleaned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function unaryRuntime(
|
||||
transport: BrowserRpcTransport,
|
||||
observations?: Readonly<{
|
||||
observe(value: BrowserRpcObservation): void;
|
||||
}>,
|
||||
) {
|
||||
return createBrowserRpcRuntime({
|
||||
...baseDependencies(transport),
|
||||
observations,
|
||||
});
|
||||
}
|
||||
|
||||
function baseDependencies(transport: BrowserRpcTransport) {
|
||||
return {
|
||||
operations: { GET_RPC_RESOURCE: unaryOperation() },
|
||||
profiles: { CONNECT_REFERENCE_UNARY: unaryProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceRequestEncoder: UNARY_ENCODER,
|
||||
},
|
||||
transports: { CONNECT_REFERENCE_UNARY: transport },
|
||||
} as const;
|
||||
}
|
||||
|
||||
function streamRuntime(
|
||||
transport: BrowserRpcTransport,
|
||||
observations?: Readonly<{
|
||||
observe(value: BrowserRpcObservation): void;
|
||||
}>,
|
||||
operation = streamOperation(),
|
||||
) {
|
||||
return createBrowserRpcRuntime({
|
||||
operations: { WATCH_RPC_RESOURCES: operation },
|
||||
profiles: { CONNECT_REFERENCE_STREAM: streamProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceStreamRequestEncoder: STREAM_ENCODER,
|
||||
},
|
||||
transports: { CONNECT_REFERENCE_STREAM: transport },
|
||||
observations,
|
||||
});
|
||||
}
|
||||
|
||||
function streamTransport(
|
||||
source: (
|
||||
signal: AbortSignal,
|
||||
) => AsyncIterable<BrowserRpcStreamFrame>,
|
||||
): BrowserRpcTransport {
|
||||
return defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "SERVER_STREAM",
|
||||
openServerStream(call) {
|
||||
return source(call.signal);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
name: string,
|
||||
encodedBytes: number,
|
||||
): BrowserRpcStreamFrame {
|
||||
return Object.freeze({
|
||||
kind: "MESSAGE",
|
||||
message: Object.freeze({ id, name }),
|
||||
encodedBytes,
|
||||
});
|
||||
}
|
||||
|
||||
async function collect<Value>(
|
||||
iterable: AsyncIterable<Result<Value, AppFailure>>,
|
||||
): Promise<readonly Result<Value, AppFailure>[]> {
|
||||
const values: Result<Value, AppFailure>[] = [];
|
||||
for await (const value of iterable) values.push(value);
|
||||
return values;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
defineBrowserRpcOperation,
|
||||
defineBrowserRpcProviderProfile,
|
||||
defineBrowserRpcRequestEncoder,
|
||||
type BrowserRpcOperationV3,
|
||||
type BrowserRpcProviderProfile,
|
||||
} from "../../../src/contracts/browser-rpc.ts";
|
||||
import {
|
||||
mappingSuccess,
|
||||
type InstalledBoundaryMapper,
|
||||
} from "../../../src/contracts/boundary-mapper.ts";
|
||||
import type { RuntimeSchemaCodec } from "../../../src/contracts/schema-registry.ts";
|
||||
|
||||
export const DESCRIPTOR_DIGEST = "a".repeat(64);
|
||||
export const RUNTIME_DIGEST = "b".repeat(64);
|
||||
|
||||
export type ResourceView = Readonly<{ id: string; label: string }>;
|
||||
|
||||
export function unaryProfile(
|
||||
overrides: Partial<BrowserRpcProviderProfile> = {},
|
||||
): BrowserRpcProviderProfile {
|
||||
return defineBrowserRpcProviderProfile({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
fixedBaseUrl: "https://rpc.example.test/base/",
|
||||
runtimeId: "connect-es-web",
|
||||
runtimeVersion: "2.1.0",
|
||||
runtimeDigest: RUNTIME_DIGEST,
|
||||
protocol: "CONNECT_HTTP",
|
||||
runtimeKind: "CONNECT_WEB_FETCH",
|
||||
clientApiKind: "PROMISE_UNARY",
|
||||
rpcKind: "UNARY",
|
||||
messageEncoding: "PROTO",
|
||||
framing: "CONNECT_BARE",
|
||||
requestMethod: "POST",
|
||||
descriptorArtifactId: "buf.example.resource.v1",
|
||||
descriptorDigest: DESCRIPTOR_DIGEST,
|
||||
allowedProcedures: ["example.resource.v1.ResourceService/GetResource"],
|
||||
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
||||
csrfProfileId: "NO_CSRF_BEARER",
|
||||
corsProfileId: "SAME_ORIGIN_RPC",
|
||||
errorProfileId: "CONNECT_ERROR_V1",
|
||||
deadlineProfileId: "RPC_TOTAL_5S",
|
||||
retryProfileId: "RPC_RETRY_NONE",
|
||||
retryOwner: "NONE",
|
||||
maxAttempts: 1,
|
||||
backoffMs: [],
|
||||
retryableFailures: [],
|
||||
maxRetryAfterMs: 0,
|
||||
deadlineDialect: "CONNECT_TIMEOUT_MS",
|
||||
cancelDialect: "ABORT_SIGNAL",
|
||||
rawByteCeilingOwner: "EDGE_AND_TRANSPORT",
|
||||
streamMessageCompression: "IDENTITY_ONLY",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
export function unaryOperation(
|
||||
overrides: Partial<BrowserRpcOperationV3> = {},
|
||||
): BrowserRpcOperationV3 {
|
||||
return defineBrowserRpcOperation({
|
||||
contractVersion: 3,
|
||||
operationId: "GET_RPC_RESOURCE",
|
||||
owner: "feature-browser-rpc-test",
|
||||
protocol: "CONNECT_HTTP",
|
||||
semantics: "QUERY",
|
||||
replayPolicy: "SAFE",
|
||||
idempotencyKeyPolicy: "NONE",
|
||||
idempotencyLevel: "IDEMPOTENT",
|
||||
dataClassification: "INTERNAL",
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
fullyQualifiedService: "example.resource.v1.ResourceService",
|
||||
method: "GetResource",
|
||||
rpcKind: "UNARY",
|
||||
requestMessageId: "example.resource.v1.GetResourceRequest",
|
||||
responseMessageId: "example.resource.v1.Resource",
|
||||
descriptorArtifactId: "buf.example.resource.v1",
|
||||
descriptorDigest: DESCRIPTOR_DIGEST,
|
||||
requestSchemaId: "RpcResourceRequest",
|
||||
responseSchemaId: "RpcResourceResponse",
|
||||
requestEncoderId: "RpcResourceRequestEncoder",
|
||||
mapperId: "RpcResourceMapper",
|
||||
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
||||
csrfProfileId: "NO_CSRF_BEARER",
|
||||
errorProfileId: "CONNECT_ERROR_V1",
|
||||
deadlineProfileId: "RPC_TOTAL_5S",
|
||||
retryProfileId: "RPC_RETRY_NONE",
|
||||
serverStateProfileId: "RpcResourceQuery",
|
||||
maxRequestMessageBytes: 1_024,
|
||||
maxResponseMessageBytes: 4_096,
|
||||
maxResponseMessages: 1,
|
||||
maxTotalResponseBytes: 4_096,
|
||||
maxBufferedBytes: 4_096,
|
||||
idleDeadlineMs: null,
|
||||
totalDeadlineMs: 5_000,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
export function streamProfile(
|
||||
overrides: Partial<BrowserRpcProviderProfile> = {},
|
||||
): BrowserRpcProviderProfile {
|
||||
return defineBrowserRpcProviderProfile({
|
||||
...unaryProfile(),
|
||||
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
|
||||
clientApiKind: "ASYNC_ITERABLE",
|
||||
rpcKind: "SERVER_STREAM",
|
||||
framing: "CONNECT_ENVELOPE",
|
||||
allowedProcedures: [
|
||||
"example.resource.v1.ResourceService/WatchResources",
|
||||
],
|
||||
retryProfileId: "RPC_STREAM_RETRY_NONE",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
export function streamOperation(
|
||||
overrides: Partial<BrowserRpcOperationV3> = {},
|
||||
): BrowserRpcOperationV3 {
|
||||
return defineBrowserRpcOperation({
|
||||
...unaryOperation(),
|
||||
operationId: "WATCH_RPC_RESOURCES",
|
||||
semantics: "SERVER_STREAM",
|
||||
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
|
||||
method: "WatchResources",
|
||||
rpcKind: "SERVER_STREAM",
|
||||
requestEncoderId: "RpcResourceStreamRequestEncoder",
|
||||
retryProfileId: "RPC_STREAM_RETRY_NONE",
|
||||
serverStateProfileId: null,
|
||||
maxResponseMessages: 4,
|
||||
maxTotalResponseBytes: 16_384,
|
||||
maxBufferedBytes: 8_192,
|
||||
idleDeadlineMs: 1_000,
|
||||
totalDeadlineMs: 10_000,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
export const SCHEMA_CODECS = Object.freeze({
|
||||
RpcResourceRequest: Object.freeze({
|
||||
schemaId: "RpcResourceRequest",
|
||||
parse(value: unknown) {
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { resourceId?: unknown }).resourceId === "string"
|
||||
) {
|
||||
return Object.freeze({
|
||||
success: true,
|
||||
data: Object.freeze({
|
||||
resourceId: (value as { resourceId: string }).resourceId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
success: false,
|
||||
issues: Object.freeze([
|
||||
Object.freeze({ path: "resourceId", code: "INVALID_STRING" }),
|
||||
]),
|
||||
});
|
||||
},
|
||||
}),
|
||||
RpcResourceResponse: Object.freeze({
|
||||
schemaId: "RpcResourceResponse",
|
||||
parse(value: unknown) {
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { id?: unknown }).id === "string" &&
|
||||
typeof (value as { name?: unknown }).name === "string"
|
||||
) {
|
||||
return Object.freeze({
|
||||
success: true,
|
||||
data: Object.freeze({
|
||||
id: (value as { id: string }).id,
|
||||
name: (value as { name: string }).name,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
success: false,
|
||||
issues: Object.freeze([
|
||||
Object.freeze({ path: "", code: "INVALID_RESOURCE" }),
|
||||
]),
|
||||
});
|
||||
},
|
||||
}),
|
||||
} satisfies Readonly<Record<string, RuntimeSchemaCodec>>);
|
||||
|
||||
export const MAPPERS = Object.freeze({
|
||||
RpcResourceMapper: Object.freeze({
|
||||
mapperId: "RpcResourceMapper",
|
||||
mapperVersion: 1,
|
||||
inputSchemaId: "RpcResourceResponse",
|
||||
outputContractId: "ResourceView",
|
||||
owner: "feature-browser-rpc-test",
|
||||
maxOutputItems: 1,
|
||||
map(input: unknown) {
|
||||
const resource = input as Readonly<{ id: string; name: string }>;
|
||||
return mappingSuccess<ResourceView>(
|
||||
Object.freeze({ id: resource.id, label: resource.name }),
|
||||
);
|
||||
},
|
||||
}),
|
||||
} satisfies Readonly<Record<string, InstalledBoundaryMapper>>);
|
||||
|
||||
export const UNARY_ENCODER = defineBrowserRpcRequestEncoder({
|
||||
encoderId: "RpcResourceRequestEncoder",
|
||||
operationId: "GET_RPC_RESOURCE",
|
||||
encode(value) {
|
||||
const request = value as Readonly<{ resourceId: string }>;
|
||||
return Object.freeze({
|
||||
ok: true,
|
||||
value: Object.freeze({ resourceId: request.resourceId }),
|
||||
encodedBytes: request.resourceId.length,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const STREAM_ENCODER = defineBrowserRpcRequestEncoder({
|
||||
encoderId: "RpcResourceStreamRequestEncoder",
|
||||
operationId: "WATCH_RPC_RESOURCES",
|
||||
encode(value) {
|
||||
const request = value as Readonly<{ resourceId: string }>;
|
||||
return Object.freeze({
|
||||
ok: true,
|
||||
value: Object.freeze({ resourceId: request.resourceId }),
|
||||
encodedBytes: request.resourceId.length,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export function isResourceView(value: unknown): value is ResourceView {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { id?: unknown }).id === "string" &&
|
||||
typeof (value as { label?: unknown }).label === "string"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user