@bleedingdev/modern-js-runtime-extensions 3.8.3-ultramodern.2
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/LICENSE +21 -0
- package/dist/cjs/helmetCompat.js +419 -0
- package/dist/cjs/html.js +45 -0
- package/dist/cjs/index.js +83 -0
- package/dist/cjs/navigationWarmup.js +59 -0
- package/dist/cjs/node.js +143 -0
- package/dist/cjs/rendererHead.js +263 -0
- package/dist/cjs/rscHtmlStream.js +128 -0
- package/dist/cjs/safeFailure.js +125 -0
- package/dist/cjs/shellStream.js +76 -0
- package/dist/esm/helmetCompat.mjs +357 -0
- package/dist/esm/html.mjs +4 -0
- package/dist/esm/index.mjs +4 -0
- package/dist/esm/navigationWarmup.mjs +21 -0
- package/dist/esm/node.mjs +61 -0
- package/dist/esm/rendererHead.mjs +162 -0
- package/dist/esm/rscHtmlStream.mjs +86 -0
- package/dist/esm/safeFailure.mjs +78 -0
- package/dist/esm/shellStream.mjs +38 -0
- package/dist/esm-node/helmetCompat.mjs +358 -0
- package/dist/esm-node/html.mjs +5 -0
- package/dist/esm-node/index.mjs +5 -0
- package/dist/esm-node/navigationWarmup.mjs +22 -0
- package/dist/esm-node/node.mjs +62 -0
- package/dist/esm-node/rendererHead.mjs +163 -0
- package/dist/esm-node/rscHtmlStream.mjs +87 -0
- package/dist/esm-node/safeFailure.mjs +79 -0
- package/dist/esm-node/shellStream.mjs +39 -0
- package/dist/types/helmetCompat.d.ts +75 -0
- package/dist/types/html.d.ts +2 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/navigationWarmup.d.ts +8 -0
- package/dist/types/node.d.ts +11 -0
- package/dist/types/rendererHead.d.ts +17 -0
- package/dist/types/rscHtmlStream.d.ts +4 -0
- package/dist/types/safeFailure.d.ts +17 -0
- package/dist/types/shellStream.d.ts +1 -0
- package/package.json +90 -0
- package/rslib.config.mts +4 -0
- package/rstest.config.mts +7 -0
- package/src/helmetCompat.ts +628 -0
- package/src/html.ts +11 -0
- package/src/index.ts +4 -0
- package/src/navigationWarmup.ts +39 -0
- package/src/node.ts +93 -0
- package/src/rendererHead.ts +236 -0
- package/src/rscHtmlStream.ts +142 -0
- package/src/safeFailure.ts +137 -0
- package/src/shellStream.ts +53 -0
- package/tests/rendererHead.test.ts +299 -0
- package/tests/rscHtmlStream.test.ts +153 -0
- package/tests/safeFailure.test.ts +154 -0
- package/tests/shellStream.test.ts +85 -0
- package/tsconfig.json +10 -0
package/src/node.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { pipeline, Transform } from 'node:stream';
|
|
2
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
3
|
+
import { abortHeadRender, createHeadChunkProcessor } from './rendererHead';
|
|
4
|
+
|
|
5
|
+
export * from './rendererHead';
|
|
6
|
+
|
|
7
|
+
const toError = (reason: unknown): Error =>
|
|
8
|
+
reason instanceof Error ? reason : new Error(String(reason));
|
|
9
|
+
|
|
10
|
+
export const createNodeHeadMarkerStripper = (
|
|
11
|
+
context: object,
|
|
12
|
+
terminalMarker?: string,
|
|
13
|
+
): Transform => {
|
|
14
|
+
const decoder = new StringDecoder('utf8');
|
|
15
|
+
const processor = createHeadChunkProcessor(context);
|
|
16
|
+
let terminalCarry = '';
|
|
17
|
+
let sawTerminalMarker = terminalMarker === undefined;
|
|
18
|
+
const observeTerminalMarker = (output: string): string => {
|
|
19
|
+
if (!sawTerminalMarker && terminalMarker !== undefined) {
|
|
20
|
+
const observed = terminalCarry + output;
|
|
21
|
+
sawTerminalMarker = observed.includes(terminalMarker);
|
|
22
|
+
terminalCarry = observed.slice(
|
|
23
|
+
Math.max(0, observed.length - terminalMarker.length + 1),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return output;
|
|
27
|
+
};
|
|
28
|
+
return new Transform({
|
|
29
|
+
transform(chunk, _encoding, callback) {
|
|
30
|
+
try {
|
|
31
|
+
callback(
|
|
32
|
+
null,
|
|
33
|
+
observeTerminalMarker(
|
|
34
|
+
processor.push(
|
|
35
|
+
decoder.write(
|
|
36
|
+
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
callback(toError(error));
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
flush(callback) {
|
|
46
|
+
try {
|
|
47
|
+
const output = observeTerminalMarker(processor.finish(decoder.end()));
|
|
48
|
+
callback(
|
|
49
|
+
null,
|
|
50
|
+
sawTerminalMarker ? output : `${output}${terminalMarker}`,
|
|
51
|
+
);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
callback(toError(error));
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
destroy(error, callback) {
|
|
57
|
+
try {
|
|
58
|
+
abortHeadRender(context);
|
|
59
|
+
callback(error);
|
|
60
|
+
} catch (abortError) {
|
|
61
|
+
callback(error ?? toError(abortError));
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const createOnceErrorReporter = (
|
|
68
|
+
onError?: (error: unknown) => void,
|
|
69
|
+
): ((error: unknown) => void) => {
|
|
70
|
+
let reported = false;
|
|
71
|
+
return error => {
|
|
72
|
+
if (!reported) {
|
|
73
|
+
reported = true;
|
|
74
|
+
onError?.(error);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const pipeNodeHeadStream = (options: {
|
|
80
|
+
source: NodeJS.ReadWriteStream;
|
|
81
|
+
destination: NodeJS.ReadWriteStream;
|
|
82
|
+
context: object;
|
|
83
|
+
terminalMarker?: string;
|
|
84
|
+
onError?: (error: Error) => void;
|
|
85
|
+
}): void => {
|
|
86
|
+
const { source, destination, context, terminalMarker, onError } = options;
|
|
87
|
+
const stripper = createNodeHeadMarkerStripper(context, terminalMarker);
|
|
88
|
+
pipeline(source, stripper, destination, error => {
|
|
89
|
+
if (error !== null) {
|
|
90
|
+
onError?.(error);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
const MARKER_ATTRIBUTE = 'data-modern-helmet';
|
|
2
|
+
const MARKER_PREFIX = `<template ${MARKER_ATTRIBUTE}="`;
|
|
3
|
+
const MARKER_SUFFIX = '"></template>';
|
|
4
|
+
const RECORD_ID_WIDTH = 12;
|
|
5
|
+
const MARKER_LENGTH =
|
|
6
|
+
MARKER_PREFIX.length + 1 + 36 + RECORD_ID_WIDTH + MARKER_SUFFIX.length;
|
|
7
|
+
|
|
8
|
+
export type RendererHeadMarkerProps = Record<typeof MARKER_ATTRIBUTE, string>;
|
|
9
|
+
export * from './helmetCompat';
|
|
10
|
+
|
|
11
|
+
type PublishRecords = (records: unknown[]) => void;
|
|
12
|
+
|
|
13
|
+
type HeadTransaction = {
|
|
14
|
+
recordsByToken: Map<string, unknown>;
|
|
15
|
+
committedRecords: unknown[];
|
|
16
|
+
committedTokens: Set<string>;
|
|
17
|
+
nextRecordId: number;
|
|
18
|
+
nonce: string;
|
|
19
|
+
previousRecords: unknown[];
|
|
20
|
+
sealed: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type HeadState = {
|
|
24
|
+
publishedRecords: unknown[];
|
|
25
|
+
publish?: PublishRecords;
|
|
26
|
+
transaction?: HeadTransaction;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const HEAD_STATE = Symbol('modern.rendererHead');
|
|
30
|
+
type HeadContext = { [HEAD_STATE]?: HeadState };
|
|
31
|
+
|
|
32
|
+
const getState = (context: object): HeadState => {
|
|
33
|
+
const headContext = context as HeadContext;
|
|
34
|
+
let state = headContext[HEAD_STATE];
|
|
35
|
+
if (state === undefined) {
|
|
36
|
+
state = { publishedRecords: [] };
|
|
37
|
+
Object.defineProperty(headContext, HEAD_STATE, { value: state });
|
|
38
|
+
}
|
|
39
|
+
return state;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const formatId = (value: number): string =>
|
|
43
|
+
value.toString(36).padStart(RECORD_ID_WIDTH, '0');
|
|
44
|
+
|
|
45
|
+
const createToken = (transaction: HeadTransaction): string => {
|
|
46
|
+
const token = `h${transaction.nonce}${formatId(transaction.nextRecordId)}`;
|
|
47
|
+
transaction.nextRecordId += 1;
|
|
48
|
+
return token;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const beginHeadRender = (context: object): void => {
|
|
52
|
+
const state = getState(context);
|
|
53
|
+
state.transaction = {
|
|
54
|
+
recordsByToken: new Map(),
|
|
55
|
+
committedRecords: [],
|
|
56
|
+
committedTokens: new Set(),
|
|
57
|
+
nextRecordId: 0,
|
|
58
|
+
nonce: globalThis.crypto.randomUUID(),
|
|
59
|
+
previousRecords: state.publishedRecords,
|
|
60
|
+
sealed: false,
|
|
61
|
+
};
|
|
62
|
+
state.publish?.([]);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const collectHeadRecord = <RecordType>(
|
|
66
|
+
context: object,
|
|
67
|
+
createRecord: () => RecordType,
|
|
68
|
+
publish: (records: RecordType[]) => void,
|
|
69
|
+
): RendererHeadMarkerProps | null | undefined => {
|
|
70
|
+
const state = getState(context);
|
|
71
|
+
const transaction = state.transaction;
|
|
72
|
+
if (transaction === undefined) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const hadPublisher = state.publish !== undefined;
|
|
77
|
+
state.publish = records => publish(records as RecordType[]);
|
|
78
|
+
if (!hadPublisher) {
|
|
79
|
+
state.publish([]);
|
|
80
|
+
}
|
|
81
|
+
if (transaction.sealed) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const token = createToken(transaction);
|
|
86
|
+
transaction.recordsByToken.set(token, createRecord());
|
|
87
|
+
return { [MARKER_ATTRIBUTE]: token };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const collectHeadState = <RecordType, StateType>(
|
|
91
|
+
context: object,
|
|
92
|
+
createRecord: () => RecordType,
|
|
93
|
+
derive: (records: RecordType[]) => StateType,
|
|
94
|
+
target: { helmet?: StateType },
|
|
95
|
+
): RendererHeadMarkerProps | null | undefined =>
|
|
96
|
+
collectHeadRecord(context, createRecord, records => {
|
|
97
|
+
target.helmet = derive(records);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const consumeMarkers = (context: object, html: string): string => {
|
|
101
|
+
const transaction = getState(context).transaction;
|
|
102
|
+
if (transaction === undefined) {
|
|
103
|
+
return html;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let cursor = 0;
|
|
107
|
+
let stripped = '';
|
|
108
|
+
while (cursor < html.length) {
|
|
109
|
+
const start = html.indexOf(MARKER_PREFIX, cursor);
|
|
110
|
+
if (start === -1) {
|
|
111
|
+
stripped += html.slice(cursor);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
const tokenStart = start + MARKER_PREFIX.length;
|
|
115
|
+
const end = html.indexOf(MARKER_SUFFIX, tokenStart);
|
|
116
|
+
if (end === -1) {
|
|
117
|
+
stripped += html.slice(cursor);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const token = html.slice(tokenStart, end);
|
|
122
|
+
if (!transaction.recordsByToken.has(token)) {
|
|
123
|
+
stripped += html.slice(cursor, end + MARKER_SUFFIX.length);
|
|
124
|
+
} else {
|
|
125
|
+
stripped += html.slice(cursor, start);
|
|
126
|
+
if (!transaction.sealed && !transaction.committedTokens.has(token)) {
|
|
127
|
+
transaction.committedTokens.add(token);
|
|
128
|
+
transaction.committedRecords.push(
|
|
129
|
+
transaction.recordsByToken.get(token),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
cursor = end + MARKER_SUFFIX.length;
|
|
134
|
+
}
|
|
135
|
+
return stripped;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const publish = (context: object): void => {
|
|
139
|
+
const state = getState(context);
|
|
140
|
+
const records = state.transaction?.committedRecords ?? [];
|
|
141
|
+
state.publishedRecords = records;
|
|
142
|
+
state.publish?.(records);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const publishHeadRender = (context: object): void => {
|
|
146
|
+
const transaction = getState(context).transaction;
|
|
147
|
+
if (transaction !== undefined) {
|
|
148
|
+
transaction.sealed = true;
|
|
149
|
+
publish(context);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const finishHeadRender = (context: object): void => {
|
|
154
|
+
const state = getState(context);
|
|
155
|
+
if (state.transaction !== undefined) {
|
|
156
|
+
if (!state.transaction.sealed) {
|
|
157
|
+
publish(context);
|
|
158
|
+
}
|
|
159
|
+
state.transaction = undefined;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const completeHeadRender = (context: object, html: string): string => {
|
|
164
|
+
const stripped = consumeMarkers(context, html);
|
|
165
|
+
finishHeadRender(context);
|
|
166
|
+
return stripped;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const abortHeadRender = (context: object): void => {
|
|
170
|
+
const state = getState(context);
|
|
171
|
+
const transaction = state.transaction;
|
|
172
|
+
if (transaction !== undefined) {
|
|
173
|
+
state.publishedRecords = transaction.previousRecords;
|
|
174
|
+
state.publish?.(transaction.previousRecords);
|
|
175
|
+
state.transaction = undefined;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const findCarryStart = (input: string): number => {
|
|
180
|
+
const windowStart = Math.max(0, input.length - MARKER_LENGTH + 1);
|
|
181
|
+
const markerStart = input.lastIndexOf(MARKER_PREFIX);
|
|
182
|
+
if (
|
|
183
|
+
markerStart >= windowStart &&
|
|
184
|
+
input.length - markerStart < MARKER_LENGTH
|
|
185
|
+
) {
|
|
186
|
+
return markerStart;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const maxPrefixLength = Math.min(MARKER_PREFIX.length - 1, input.length);
|
|
190
|
+
for (let length = maxPrefixLength; length > 0; length -= 1) {
|
|
191
|
+
if (MARKER_PREFIX.startsWith(input.slice(-length))) {
|
|
192
|
+
return input.length - length;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return input.length;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export const createHeadChunkProcessor = (context: object) => {
|
|
199
|
+
let carry = '';
|
|
200
|
+
return {
|
|
201
|
+
push(chunk: string): string {
|
|
202
|
+
const input = carry + chunk;
|
|
203
|
+
const carryStart = findCarryStart(input);
|
|
204
|
+
const output = consumeMarkers(context, input.slice(0, carryStart));
|
|
205
|
+
carry = input.slice(carryStart);
|
|
206
|
+
return output;
|
|
207
|
+
},
|
|
208
|
+
finish(chunk = ''): string {
|
|
209
|
+
const output = completeHeadRender(context, carry + chunk);
|
|
210
|
+
carry = '';
|
|
211
|
+
return output;
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
export const createWebHeadMarkerStripper = (
|
|
217
|
+
context: object,
|
|
218
|
+
): TransformStream<Uint8Array, Uint8Array> => {
|
|
219
|
+
const decoder = new TextDecoder();
|
|
220
|
+
const encoder = new TextEncoder();
|
|
221
|
+
const processor = createHeadChunkProcessor(context);
|
|
222
|
+
return new TransformStream({
|
|
223
|
+
transform(chunk, controller) {
|
|
224
|
+
const output = processor.push(decoder.decode(chunk, { stream: true }));
|
|
225
|
+
if (output) {
|
|
226
|
+
controller.enqueue(encoder.encode(output));
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
flush(controller) {
|
|
230
|
+
const output = processor.finish(decoder.decode());
|
|
231
|
+
if (output) {
|
|
232
|
+
controller.enqueue(encoder.encode(output));
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forked and modified from https://github.com/devongovett/rsc-html-stream.
|
|
3
|
+
* License: https://github.com/devongovett/rsc-html-stream/blob/main/LICENSE
|
|
4
|
+
*/
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
const closingTagsPattern = /<\/body>\s*<\/html>\s*$/i;
|
|
7
|
+
const closingTagsPrefixPattern =
|
|
8
|
+
/(?:<\/body>\s*(?:<\/html>\s*|<\/h(?:t(?:m(?:l>?)?)?)?|<\/?|<)?|<\/b(?:o(?:d(?:y>?)?)?)?|<\/?|<[^>]*)$/i;
|
|
9
|
+
|
|
10
|
+
export const RSC_HTML_CLOSING_TAIL_LIMIT = 8 * 1024;
|
|
11
|
+
|
|
12
|
+
export function injectRSCPayload(
|
|
13
|
+
rscStream: ReadableStream<Uint8Array>,
|
|
14
|
+
{ injectClosingTags = true }: { injectClosingTags?: boolean } = {},
|
|
15
|
+
): TransformStream<Uint8Array, Uint8Array> {
|
|
16
|
+
const decoder = new TextDecoder();
|
|
17
|
+
let retainedTail = '';
|
|
18
|
+
let committedClosingBody = false;
|
|
19
|
+
let committedHtmlClose = false;
|
|
20
|
+
let committedProbe = '';
|
|
21
|
+
let flightPromise: Promise<void> | undefined;
|
|
22
|
+
|
|
23
|
+
const startFlight = (
|
|
24
|
+
controller: TransformStreamDefaultController<Uint8Array>,
|
|
25
|
+
) => {
|
|
26
|
+
if (!flightPromise) {
|
|
27
|
+
flightPromise = writeRSCStream(rscStream, controller);
|
|
28
|
+
void flightPromise.catch(error => controller.error(error));
|
|
29
|
+
}
|
|
30
|
+
return flightPromise;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const enqueueHtml = (
|
|
34
|
+
value: string,
|
|
35
|
+
controller: TransformStreamDefaultController<Uint8Array>,
|
|
36
|
+
) => {
|
|
37
|
+
if (value) {
|
|
38
|
+
controller.enqueue(encoder.encode(value));
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const processHtml = async (
|
|
43
|
+
value: string,
|
|
44
|
+
controller: TransformStreamDefaultController<Uint8Array>,
|
|
45
|
+
) => {
|
|
46
|
+
if (!value) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (committedClosingBody) {
|
|
51
|
+
const probe = committedProbe + value;
|
|
52
|
+
committedHtmlClose ||= /<\/html>/i.test(probe);
|
|
53
|
+
committedProbe = probe.slice(-6);
|
|
54
|
+
enqueueHtml(value, controller);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const html = retainedTail + value;
|
|
59
|
+
retainedTail = '';
|
|
60
|
+
const suffix = html.match(closingTagsPrefixPattern)?.[0] ?? '';
|
|
61
|
+
const prefix = suffix ? html.slice(0, -suffix.length) : html;
|
|
62
|
+
enqueueHtml(prefix, controller);
|
|
63
|
+
|
|
64
|
+
if (!suffix) {
|
|
65
|
+
void startFlight(controller);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (suffix.length <= RSC_HTML_CLOSING_TAIL_LIMIT) {
|
|
69
|
+
retainedTail = suffix;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await startFlight(controller);
|
|
74
|
+
if (/<\/body>/i.test(suffix)) {
|
|
75
|
+
committedClosingBody = true;
|
|
76
|
+
committedHtmlClose = /<\/html>/i.test(suffix);
|
|
77
|
+
committedProbe = suffix.slice(-6);
|
|
78
|
+
}
|
|
79
|
+
enqueueHtml(suffix, controller);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return new TransformStream({
|
|
83
|
+
async transform(chunk, controller) {
|
|
84
|
+
await processHtml(decoder.decode(chunk, { stream: true }), controller);
|
|
85
|
+
},
|
|
86
|
+
async flush(controller) {
|
|
87
|
+
await processHtml(decoder.decode(), controller);
|
|
88
|
+
await startFlight(controller);
|
|
89
|
+
|
|
90
|
+
const retainedClosingTags = closingTagsPattern.test(retainedTail);
|
|
91
|
+
enqueueHtml(retainedTail, controller);
|
|
92
|
+
if (!injectClosingTags) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (committedClosingBody) {
|
|
96
|
+
if (!committedHtmlClose) {
|
|
97
|
+
enqueueHtml('</html>', controller);
|
|
98
|
+
}
|
|
99
|
+
} else if (!retainedClosingTags) {
|
|
100
|
+
enqueueHtml('</body></html>', controller);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function writeRSCStream(
|
|
107
|
+
rscStream: ReadableStream<Uint8Array>,
|
|
108
|
+
controller: TransformStreamDefaultController<Uint8Array>,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
111
|
+
for await (const chunk of rscStream) {
|
|
112
|
+
try {
|
|
113
|
+
writeChunk(
|
|
114
|
+
JSON.stringify(decoder.decode(chunk, { stream: true })),
|
|
115
|
+
controller,
|
|
116
|
+
);
|
|
117
|
+
} catch {
|
|
118
|
+
const base64 = JSON.stringify(btoa(String.fromCodePoint(...chunk)));
|
|
119
|
+
writeChunk(
|
|
120
|
+
`Uint8Array.from(atob(${base64}), m => m.codePointAt(0))`,
|
|
121
|
+
controller,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const remaining = decoder.decode();
|
|
127
|
+
if (remaining) {
|
|
128
|
+
writeChunk(JSON.stringify(remaining), controller);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function writeChunk(
|
|
133
|
+
chunk: string,
|
|
134
|
+
controller: TransformStreamDefaultController<Uint8Array>,
|
|
135
|
+
) {
|
|
136
|
+
const script = `(self.__FLIGHT_DATA||=[]).push(${chunk})`;
|
|
137
|
+
controller.enqueue(
|
|
138
|
+
encoder.encode(
|
|
139
|
+
`<script>${script.replace(/<!--/g, '<\\!--').replace(/<\/(script)/gi, '</\\$1')}</script>`,
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const SAFE_FAILURE_MESSAGES: Readonly<Record<number, string>> = {
|
|
2
|
+
500: 'Internal Server Error',
|
|
3
|
+
503: 'Service Unavailable',
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
const SAFE_FAILURE_CODES: Readonly<Record<number, string>> = {
|
|
7
|
+
500: 'INTERNAL_SERVER_ERROR',
|
|
8
|
+
503: 'SERVICE_UNAVAILABLE',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type SafeFailureEnvelope = {
|
|
12
|
+
success: false;
|
|
13
|
+
error: {
|
|
14
|
+
code: string;
|
|
15
|
+
message: string;
|
|
16
|
+
status: number;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type SafeFailureHttpResult = {
|
|
21
|
+
status: number;
|
|
22
|
+
body: SafeFailureEnvelope;
|
|
23
|
+
headers: Record<string, string>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const readErrorProperty = (error: unknown, key: string): unknown => {
|
|
27
|
+
if (typeof error !== 'object' || error === null) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
return (error as Record<string, unknown>)[key];
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const normalizeFailureStatus = (value: unknown): number | undefined =>
|
|
39
|
+
typeof value === 'number' &&
|
|
40
|
+
Number.isInteger(value) &&
|
|
41
|
+
value >= 400 &&
|
|
42
|
+
value <= 599
|
|
43
|
+
? value
|
|
44
|
+
: undefined;
|
|
45
|
+
|
|
46
|
+
const normalizeDelaySeconds = (value: unknown): string | undefined => {
|
|
47
|
+
if (typeof value === 'number') {
|
|
48
|
+
const seconds = Math.ceil(value);
|
|
49
|
+
return Number.isFinite(value) && value >= 0 && Number.isSafeInteger(seconds)
|
|
50
|
+
? String(seconds)
|
|
51
|
+
: undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (typeof value === 'string') {
|
|
55
|
+
const trimmed = value.trim();
|
|
56
|
+
if (!/^\d+$/u.test(trimmed)) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const seconds = Number(trimmed);
|
|
60
|
+
return Number.isSafeInteger(seconds) ? String(seconds) : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return undefined;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const normalizeHttpDate = (value: unknown): string | undefined => {
|
|
67
|
+
try {
|
|
68
|
+
if (value instanceof Date) {
|
|
69
|
+
const timestamp = Date.prototype.getTime.call(value);
|
|
70
|
+
return Number.isFinite(timestamp)
|
|
71
|
+
? new Date(timestamp).toUTCString()
|
|
72
|
+
: undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof value === 'string') {
|
|
76
|
+
const trimmed = value.trim();
|
|
77
|
+
const timestamp = Date.parse(trimmed);
|
|
78
|
+
if (Number.isFinite(timestamp)) {
|
|
79
|
+
const canonical = new Date(timestamp).toUTCString();
|
|
80
|
+
return canonical === trimmed ? canonical : undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return undefined;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const normalizeRetryAfter = (value: unknown): string | undefined =>
|
|
91
|
+
normalizeDelaySeconds(value) ?? normalizeHttpDate(value);
|
|
92
|
+
|
|
93
|
+
export const getSafeFailureStatus = (error: unknown): number =>
|
|
94
|
+
normalizeFailureStatus(readErrorProperty(error, 'status')) ??
|
|
95
|
+
normalizeFailureStatus(readErrorProperty(error, 'statusCode')) ??
|
|
96
|
+
500;
|
|
97
|
+
|
|
98
|
+
export const createSafeFailureHttpResult = (
|
|
99
|
+
error: unknown,
|
|
100
|
+
): SafeFailureHttpResult => {
|
|
101
|
+
const status = getSafeFailureStatus(error);
|
|
102
|
+
const retryAfterMs = readErrorProperty(error, 'retryAfterMs');
|
|
103
|
+
const retryAfter =
|
|
104
|
+
status === 503
|
|
105
|
+
? (normalizeRetryAfter(readErrorProperty(error, 'retryAfter')) ??
|
|
106
|
+
normalizeRetryAfter(readErrorProperty(error, 'retryAfterSeconds')) ??
|
|
107
|
+
normalizeRetryAfter(
|
|
108
|
+
typeof retryAfterMs === 'number' ? retryAfterMs / 1000 : undefined,
|
|
109
|
+
))
|
|
110
|
+
: undefined;
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
status,
|
|
114
|
+
body: {
|
|
115
|
+
success: false,
|
|
116
|
+
error: {
|
|
117
|
+
code: SAFE_FAILURE_CODES[status] ?? 'REQUEST_FAILED',
|
|
118
|
+
message: SAFE_FAILURE_MESSAGES[status] ?? 'Request failed',
|
|
119
|
+
status,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
headers: {
|
|
123
|
+
'content-type': 'application/json; charset=utf-8',
|
|
124
|
+
...(retryAfter === undefined ? {} : { 'Retry-After': retryAfter }),
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const createSafeJsonFailureResponse = (error: unknown): Response => {
|
|
130
|
+
const result = createSafeFailureHttpResult(error);
|
|
131
|
+
return new Response(JSON.stringify(result.body), {
|
|
132
|
+
status: result.status,
|
|
133
|
+
headers: result.headers,
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const createSafeFailureResponse = createSafeJsonFailureResponse;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { abortHeadRender, createHeadChunkProcessor } from './rendererHead';
|
|
2
|
+
|
|
3
|
+
const MISSING_SHELL_MARKER_ERROR =
|
|
4
|
+
'React SSR stream ended before the shell marker';
|
|
5
|
+
|
|
6
|
+
export const createConservingWebShellStream = (
|
|
7
|
+
source: ReadableStream<Uint8Array>,
|
|
8
|
+
context: object,
|
|
9
|
+
marker: string,
|
|
10
|
+
onMissingMarker?: (error: Error) => void,
|
|
11
|
+
): ReadableStream<Uint8Array> => {
|
|
12
|
+
const decoder = new TextDecoder();
|
|
13
|
+
const encoder = new TextEncoder();
|
|
14
|
+
const headProcessor = createHeadChunkProcessor(context);
|
|
15
|
+
let markerCarry = '';
|
|
16
|
+
let markerFound = false;
|
|
17
|
+
|
|
18
|
+
const observeMarker = (chunk: string) => {
|
|
19
|
+
if (markerFound) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const candidate = markerCarry + chunk;
|
|
23
|
+
markerFound = candidate.includes(marker);
|
|
24
|
+
markerCarry = markerFound ? '' : candidate.slice(-(marker.length - 1));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return source.pipeThrough(
|
|
28
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
29
|
+
transform(chunk, controller) {
|
|
30
|
+
const decoded = decoder.decode(chunk, { stream: true });
|
|
31
|
+
observeMarker(decoded);
|
|
32
|
+
const output = headProcessor.push(decoded);
|
|
33
|
+
if (output) {
|
|
34
|
+
controller.enqueue(encoder.encode(output));
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
flush(controller) {
|
|
38
|
+
const decoded = decoder.decode();
|
|
39
|
+
observeMarker(decoded);
|
|
40
|
+
if (!markerFound) {
|
|
41
|
+
abortHeadRender(context);
|
|
42
|
+
const error = new Error(MISSING_SHELL_MARKER_ERROR);
|
|
43
|
+
onMissingMarker?.(error);
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
const output = headProcessor.finish(decoded);
|
|
47
|
+
if (output) {
|
|
48
|
+
controller.enqueue(encoder.encode(output));
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
};
|