Skip to content

Commit

Permalink
feat: <:idle> state for Requests (#9530)
Browse files Browse the repository at this point in the history
  • Loading branch information
runspired authored Aug 31, 2024
1 parent a33aa3b commit bd5d2ba
Show file tree
Hide file tree
Showing 17 changed files with 426 additions and 243 deletions.
6 changes: 5 additions & 1 deletion packages/build-config/src/-private/utils/get-env.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
export function getEnv() {
const { EMBER_ENV, IS_TESTING, EMBER_CLI_TEST_COMMAND, NODE_ENV } = process.env;
const { EMBER_ENV, IS_TESTING, EMBER_CLI_TEST_COMMAND, NODE_ENV, CI, IS_RECORDING } = process.env;
const PRODUCTION = EMBER_ENV === 'production' || (!EMBER_ENV && NODE_ENV === 'production');
const DEBUG = !PRODUCTION;
const TESTING = DEBUG || Boolean(EMBER_ENV === 'test' || IS_TESTING || EMBER_CLI_TEST_COMMAND);
const SHOULD_RECORD = Boolean(!CI || IS_RECORDING);

return {
TESTING,
PRODUCTION,
DEBUG,
IS_RECORDING: Boolean(IS_RECORDING),
IS_CI: Boolean(CI),
SHOULD_RECORD,
};
}
3 changes: 3 additions & 0 deletions packages/build-config/src/babel-macros.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ export function macros() {
TESTING: true,
PRODUCTION: true,
DEBUG: true,
IS_RECORDING: true,
IS_CI: true,
SHOULD_RECORD: true,
},
},
'@warp-drive/build-config/env',
Expand Down
3 changes: 3 additions & 0 deletions packages/build-config/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export const DEBUG: boolean = true;
export const PRODUCTION: boolean = true;
export const TESTING: boolean = true;
export const IS_RECORDING: boolean = true;
export const IS_CI: boolean = true;
export const SHOULD_RECORD: boolean = true;
66 changes: 60 additions & 6 deletions packages/ember/src/-private/request.gts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ function notNull<T>(x: T | null) {
const not = (x: unknown) => !x;
// default to 30 seconds unavailable before we refresh
const DEFAULT_DEADLINE = 30_000;
const IdleBlockMissingError = new Error(
'No idle block provided for <Request> component, and no query or request was provided.'
);

let consume = service;
if (macroCondition(moduleExists('ember-provide-consume-context'))) {
Expand Down Expand Up @@ -63,6 +66,7 @@ interface RequestSignature<T, RT> {
autorefreshBehavior?: 'refresh' | 'reload' | 'policy';
};
Blocks: {
idle: [];
loading: [state: RequestLoadingState];
cancelled: [
error: StructuredErrorDocument,
Expand Down Expand Up @@ -145,6 +149,7 @@ export class Request<T, RT> extends Component<RequestSignature<T, RT>> {
declare intervalStart: number | null;
declare nextInterval: number | null;
declare invalidated: boolean;
declare isUpdating: boolean;

/**
* The event listener for network status changes,
Expand Down Expand Up @@ -190,8 +195,27 @@ export class Request<T, RT> extends Component<RequestSignature<T, RT>> {
this.nextInterval = null;

this.installListeners();
this.updateSubscriptions();
void this.scheduleInterval();
void this.beginPolling();
}

async beginPolling() {
// await the initial request
try {
await this.request;
} catch {
// ignore errors here, we just want to wait for the request to finish
} finally {
if (!this.isDestroyed) {
void this.scheduleInterval();
}
}
}

@cached
get isIdle() {
const { request, query } = this.args;

return Boolean(!request && !query);
}

@cached
Expand Down Expand Up @@ -263,7 +287,10 @@ export class Request<T, RT> extends Component<RequestSignature<T, RT>> {
}

updateSubscriptions() {
const requestId = this.request.lid;
if (this.isIdle) {
return;
}
const requestId = this._request.lid;

// if we're already subscribed to this request, we don't need to do anything
if (this._subscribedTo === requestId) {
Expand All @@ -275,9 +302,15 @@ export class Request<T, RT> extends Component<RequestSignature<T, RT>> {

// if we have a request, we need to subscribe to it
if (requestId) {
this._subscribedTo = requestId;
this._subscription = this.store.notifications.subscribe(
requestId,
(_id: StableDocumentIdentifier, op: 'invalidated' | 'state' | 'added' | 'updated' | 'removed') => {
// ignore subscription events that occur while our own component's request
// is ocurring
if (this.isUpdating) {
return;
}
switch (op) {
case 'invalidated': {
// if we're subscribed to invalidations, we need to update
Expand Down Expand Up @@ -366,6 +399,9 @@ export class Request<T, RT> extends Component<RequestSignature<T, RT>> {
* @internal
*/
maybeUpdate(mode?: 'reload' | 'refresh' | 'policy' | 'invalidated', silent?: boolean): void {
if (this.isIdle) {
return;
}
const canAttempt = Boolean(this.isOnline && !this.isHidden && (mode || this.autorefreshTypes.size));

if (!canAttempt) {
Expand All @@ -391,7 +427,7 @@ export class Request<T, RT> extends Component<RequestSignature<T, RT>> {
const { autorefreshThreshold } = this.args;

if (intervalStart && typeof autorefreshThreshold === 'number' && autorefreshThreshold > 0) {
shouldAttempt = Boolean(Date.now() - intervalStart > autorefreshThreshold);
shouldAttempt = Boolean(Date.now() - intervalStart >= autorefreshThreshold);
}
}

Expand Down Expand Up @@ -424,13 +460,20 @@ export class Request<T, RT> extends Component<RequestSignature<T, RT>> {
!request.store || request.store === this.store
);

this.isUpdating = true;
this._latestRequest = wasStoreRequest ? this.store.request(request) : this.store.requestManager.request(request);

if (val !== 'refresh') {
this._localRequest = this._latestRequest;
}

void this.scheduleInterval();
void this._latestRequest.finally(() => {
this.isUpdating = false;
});
} else {
// TODO probably want this
// void this.scheduleInterval();
}
}

Expand Down Expand Up @@ -501,7 +544,7 @@ export class Request<T, RT> extends Component<RequestSignature<T, RT>> {
}

@cached
get request(): Future<RT> {
get _request(): Future<RT> {
const { request, query } = this.args;
assert(`Cannot use both @request and @query args with the <Request> component`, !request || !query);
const { _localRequest, _originalRequest, _originalQuery } = this;
Expand All @@ -522,6 +565,13 @@ export class Request<T, RT> extends Component<RequestSignature<T, RT>> {
return this.store.request<RT, T>(query);
}

@cached
get request(): Future<RT> {
const request = this._request;
this.updateSubscriptions();
return request;
}

get store(): Store {
const store = this.args.store || this._store;
assert(
Expand All @@ -542,7 +592,11 @@ export class Request<T, RT> extends Component<RequestSignature<T, RT>> {
}

<template>
{{#if this.reqState.isLoading}}
{{#if (and this.isIdle (has-block "idle"))}}
{{yield to="idle"}}
{{else if this.isIdle}}
<Throw @error={{IdleBlockMissingError}} />
{{else if this.reqState.isLoading}}
{{yield this.reqState.loadingState to="loading"}}
{{else if (and this.reqState.isCancelled (has-block "cancelled"))}}
{{yield (notNull this.reqState.error) this.errorFeatures to="cancelled"}}
Expand Down
176 changes: 98 additions & 78 deletions packages/holodeck/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,96 +147,116 @@ function replayRequest(context, cacheKey) {

function createTestHandler(projectRoot) {
const TestHandler = async (context) => {
const { req } = context;

const testId = req.query('__xTestId');
const testRequestNumber = req.query('__xTestRequestNumber');
const niceUrl = getNiceUrl(req.url);
try {
const { req } = context;

const testId = req.query('__xTestId');
const testRequestNumber = req.query('__xTestRequestNumber');
const niceUrl = getNiceUrl(req.url);

if (!testId) {
context.header('Content-Type', 'application/vnd.api+json');
context.status(400);
return context.body(
JSON.stringify({
errors: [
{
status: '400',
code: 'MISSING_X_TEST_ID_HEADER',
title: 'Request to the http mock server is missing the `X-Test-Id` header',
detail:
"The `X-Test-Id` header is used to identify the test that is making the request to the mock server. This is used to ensure that the mock server is only used for the test that is currently running. If using @ember-data/request add import { MockServerHandler } from '@warp-drive/holodeck'; to your request handlers.",
source: { header: 'X-Test-Id' },
},
],
})
);
}

if (!testId) {
context.header('Content-Type', 'application/vnd.api+json');
context.status(400);
return context.body(
JSON.stringify({
errors: [
{
status: '400',
code: 'MISSING_X_TEST_ID_HEADER',
title: 'Request to the http mock server is missing the `X-Test-Id` header',
detail:
"The `X-Test-Id` header is used to identify the test that is making the request to the mock server. This is used to ensure that the mock server is only used for the test that is currently running. If using @ember-data/request add import { MockServerHandler } from '@warp-drive/holodeck'; to your request handlers.",
source: { header: 'X-Test-Id' },
},
],
})
);
}
if (!testRequestNumber) {
context.header('Content-Type', 'application/vnd.api+json');
context.status(400);
return context.body(
JSON.stringify({
errors: [
{
status: '400',
code: 'MISSING_X_TEST_REQUEST_NUMBER_HEADER',
title: 'Request to the http mock server is missing the `X-Test-Request-Number` header',
detail:
"The `X-Test-Request-Number` header is used to identify the request number for the current test. This is used to ensure that the mock server response is deterministic for the test that is currently running. If using @ember-data/request add import { MockServerHandler } from '@warp-drive/holodeck'; to your request handlers.",
source: { header: 'X-Test-Request-Number' },
},
],
})
);
}

if (!testRequestNumber) {
if (req.method === 'POST' || niceUrl === '__record') {
const payload = await req.json();
const { url, headers, method, status, statusText, body, response } = payload;
const cacheKey = generateFilepath({
projectRoot,
testId,
url,
method,
body: body ? JSON.stringify(body) : null,
testRequestNumber,
});
// allow Content-Type to be overridden
headers['Content-Type'] = headers['Content-Type'] || 'application/vnd.api+json';
// We always compress and chunk the response
headers['Content-Encoding'] = 'br';
// we don't cache since tests will often reuse similar urls for different payload
headers['Cache-Control'] = 'no-store';

const cacheDir = generateFileDir({
projectRoot,
testId,
url,
method,
testRequestNumber,
});

fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(
`${cacheKey}.meta.json`,
JSON.stringify({ url, status, statusText, headers, method, requestBody: body }, null, 2)
);
fs.writeFileSync(`${cacheKey}.body.br`, compress(JSON.stringify(response)));
context.status(204);
return context.body(null);
} else {
const body = await req.text();
const cacheKey = generateFilepath({
projectRoot,
testId,
url: niceUrl,
method: req.method,
body,
testRequestNumber,
});
return replayRequest(context, cacheKey);
}
} catch (e) {
if (e instanceof HTTPException) {
throw e;
}
context.header('Content-Type', 'application/vnd.api+json');
context.status(400);
context.status(500);
return context.body(
JSON.stringify({
errors: [
{
status: '400',
code: 'MISSING_X_TEST_REQUEST_NUMBER_HEADER',
title: 'Request to the http mock server is missing the `X-Test-Request-Number` header',
detail:
"The `X-Test-Request-Number` header is used to identify the request number for the current test. This is used to ensure that the mock server response is deterministic for the test that is currently running. If using @ember-data/request add import { MockServerHandler } from '@warp-drive/holodeck'; to your request handlers.",
source: { header: 'X-Test-Request-Number' },
status: '500',
code: 'MOCK_SERVER_ERROR',
title: 'Mock Server Error during Request',
detail: e.message,
},
],
})
);
}

if (req.method === 'POST' || niceUrl === '__record') {
const payload = await req.json();
const { url, headers, method, status, statusText, body, response } = payload;
const cacheKey = generateFilepath({
projectRoot,
testId,
url,
method,
body: body ? JSON.stringify(body) : null,
testRequestNumber,
});
// allow Content-Type to be overridden
headers['Content-Type'] = headers['Content-Type'] || 'application/vnd.api+json';
// We always compress and chunk the response
headers['Content-Encoding'] = 'br';
// we don't cache since tests will often reuse similar urls for different payload
headers['Cache-Control'] = 'no-store';

const cacheDir = generateFileDir({
projectRoot,
testId,
url,
method,
testRequestNumber,
});

fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(
`${cacheKey}.meta.json`,
JSON.stringify({ url, status, statusText, headers, method, requestBody: body }, null, 2)
);
fs.writeFileSync(`${cacheKey}.body.br`, compress(JSON.stringify(response)));
context.status(204);
return context.body(null);
} else {
const body = await req.text();
const cacheKey = generateFilepath({
projectRoot,
testId,
url: niceUrl,
method: req.method,
body,
testRequestNumber,
});
return replayRequest(context, cacheKey);
}
};

return TestHandler;
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
Loading

0 comments on commit bd5d2ba

Please sign in to comment.